implemented backward pagination for chat history with on-scroll prefetching

This commit is contained in:
2026-07-17 20:14:47 +02:00
parent 101e7c909c
commit f3cd7896d9
7 changed files with 270 additions and 40 deletions
+119 -3
View File
@@ -4,6 +4,7 @@ import 'dart:math' as math;
import 'package:flutter/widgets.dart';
import '../../../../../api/marianumcloud/talk/chat/get_chat_history.dart';
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../../api/marianumcloud/talk/chat/long_poll_chat.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
@@ -56,7 +57,22 @@ class ChatBloc
ChatState fromStorage(Map<String, dynamic> json) => ChatState.fromJson(json);
@override
Map<String, dynamic>? toStorage(ChatState state) => state.toJson();
Map<String, dynamic>? toStorage(ChatState state) {
final response = state.chatResponse;
if (response == null ||
response.data.length <= _kMaxPersistedMessages) {
return state.toJson();
}
// Keep only the newest N; trimming drops older messages, so there is
// definitely more history to page back in after a restart.
final newest = response
.sortByTimestamp()
.reversed
.take(_kMaxPersistedMessages)
.toSet();
final trimmed = GetChatResponse(newest)..headers = response.headers;
return state.copyWith(chatResponse: trimmed, hasMoreOld: true).toJson();
}
@override
Future<void> gatherData() async {
@@ -75,7 +91,16 @@ class ChatBloc
return;
}
_stopLongPoll();
add(Emit((s) => s.copyWith(currentToken: token, chatResponse: null)));
add(
Emit(
(s) => s.copyWith(
currentToken: token,
chatResponse: null,
hasMoreOld: true,
isLoadingOlder: false,
),
),
);
add(RefetchStarted<ChatState>());
_scheduleLoad(token);
}
@@ -100,6 +125,40 @@ class ChatBloc
_stopLongPoll();
}
/// Pages in the next block of messages older than the oldest currently held.
/// No-ops when nothing more can be loaded or a load is already in flight.
Future<void> loadOlder() async {
final state = innerState;
if (state == null) return;
final token = state.currentToken;
if (token.isEmpty) return;
if (state.isLoadingOlder || !state.hasMoreOld) return;
final response = state.chatResponse;
if (response == null) return;
final oldestId = _minMessageId(response);
if (oldestId <= 0) return;
add(Emit((s) => s.copyWith(isLoadingOlder: true)));
try {
final older = await GetChatHistory(
chatToken: token,
lastKnownMessageId: oldestId,
limit: _kOlderPageSize,
).run();
if (isClosed) return;
if ((innerState?.currentToken ?? '') != token) return;
if (older == null || older.data.isEmpty) {
add(Emit((s) => s.copyWith(hasMoreOld: false, isLoadingOlder: false)));
return;
}
_applyOlderResponse(older);
} on Object catch (e) {
log('Load older messages for $token failed: $e');
if (isClosed) return;
add(Emit((s) => s.copyWith(isLoadingOlder: false)));
}
}
Future<void> sendServerReadMarker(String token, int messageId) async {
try {
await SetReadMarker(
@@ -245,7 +304,15 @@ class ChatBloc
void _applyChatResponse(GetChatResponse incoming) {
final current = innerState?.chatResponse;
if (current == null) {
add(DataGathered((s) => s.copyWith(chatResponse: incoming)));
// Initial load: a short first page means there is nothing older to page in.
add(
DataGathered(
(s) => s.copyWith(
chatResponse: incoming,
hasMoreOld: incoming.data.length >= _kInitialPageSize,
),
),
);
return;
}
final byId = <int, GetChatResponseObject>{};
@@ -260,6 +327,32 @@ class ChatBloc
add(DataGathered((s) => s.copyWith(chatResponse: merged)));
}
/// Merges an older history page. Unlike [_applyChatResponse] it keeps the
/// current headers — the older page's `x-chat-last-common-read` would regress
/// the read-status shown for already-visible messages.
void _applyOlderResponse(GetChatResponse older) {
final current = innerState?.chatResponse;
if (current == null) return;
final byId = <int, GetChatResponseObject>{};
for (final m in current.data) {
byId[m.id] = m;
}
for (final m in older.data) {
byId.putIfAbsent(m.id, () => m);
}
final merged = GetChatResponse(byId.values.toSet())
..headers = current.headers;
add(
DataGathered(
(s) => s.copyWith(
chatResponse: merged,
hasMoreOld: older.data.length >= _kOlderPageSize,
isLoadingOlder: false,
),
),
);
}
int _maxMessageId(GetChatResponse? response) {
if (response == null) return 0;
var max = 0;
@@ -269,6 +362,16 @@ class ChatBloc
return max;
}
int _minMessageId(GetChatResponse? response) {
if (response == null) return 0;
var min = 0;
for (final m in response.data) {
if (m.id <= 0) continue; // skip dummies
if (min == 0 || m.id < min) min = m.id;
}
return min;
}
/// Mirrors the server's own `lastMessage` selection (comments + voice only).
GetChatResponseObject? _pickDisplayMessage(GetChatResponse response) {
GetChatResponseObject? best;
@@ -288,3 +391,16 @@ class ChatBloc
}
const _kLongPollLastGivenHeader = 'x-chat-last-given';
/// Small first page shown on open (keeps the initial request cheap). Must match
/// the `limit` in GetChatCache (get_chat_cache.dart).
const _kInitialPageSize = 50;
/// Upper bound on how many messages are persisted via HydratedBloc. The full
/// scrolled history stays in memory at runtime; only the newest this-many
/// survive a restart (older ones are re-fetchable via scroll-up).
const _kMaxPersistedMessages = 500;
/// Larger block fetched per scroll-up so paging back through history needs
/// fewer round trips.
const _kOlderPageSize = 200;