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;
@@ -11,6 +11,8 @@ abstract class ChatState with _$ChatState {
@Default('') String currentToken,
GetChatResponse? chatResponse,
int? referenceMessageId,
@Default(true) bool hasMoreOld,
@Default(false) bool isLoadingOlder,
}) = _ChatState;
factory ChatState.fromJson(Map<String, Object?> json) =>
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ChatState {
String get currentToken; GetChatResponse? get chatResponse; int? get referenceMessageId;
String get currentToken; GetChatResponse? get chatResponse; int? get referenceMessageId; bool get hasMoreOld; bool get isLoadingOlder;
/// Create a copy of ChatState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -28,16 +28,16 @@ $ChatStateCopyWith<ChatState> get copyWith => _$ChatStateCopyWithImpl<ChatState>
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId));
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId)&&(identical(other.hasMoreOld, hasMoreOld) || other.hasMoreOld == hasMoreOld)&&(identical(other.isLoadingOlder, isLoadingOlder) || other.isLoadingOlder == isLoadingOlder));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId);
int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId,hasMoreOld,isLoadingOlder);
@override
String toString() {
return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId)';
return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId, hasMoreOld: $hasMoreOld, isLoadingOlder: $isLoadingOlder)';
}
@@ -48,7 +48,7 @@ abstract mixin class $ChatStateCopyWith<$Res> {
factory $ChatStateCopyWith(ChatState value, $Res Function(ChatState) _then) = _$ChatStateCopyWithImpl;
@useResult
$Res call({
String currentToken, GetChatResponse? chatResponse, int? referenceMessageId
String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder
});
@@ -65,12 +65,14 @@ class _$ChatStateCopyWithImpl<$Res>
/// Create a copy of ChatState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,Object? hasMoreOld = null,Object? isLoadingOlder = null,}) {
return _then(_self.copyWith(
currentToken: null == currentToken ? _self.currentToken : currentToken // ignore: cast_nullable_to_non_nullable
as String,chatResponse: freezed == chatResponse ? _self.chatResponse : chatResponse // ignore: cast_nullable_to_non_nullable
as GetChatResponse?,referenceMessageId: freezed == referenceMessageId ? _self.referenceMessageId : referenceMessageId // ignore: cast_nullable_to_non_nullable
as int?,
as int?,hasMoreOld: null == hasMoreOld ? _self.hasMoreOld : hasMoreOld // ignore: cast_nullable_to_non_nullable
as bool,isLoadingOlder: null == isLoadingOlder ? _self.isLoadingOlder : isLoadingOlder // ignore: cast_nullable_to_non_nullable
as bool,
));
}
@@ -155,10 +157,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ChatState() when $default != null:
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);case _:
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId,_that.hasMoreOld,_that.isLoadingOlder);case _:
return orElse();
}
@@ -176,10 +178,10 @@ return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder) $default,) {final _that = this;
switch (_that) {
case _ChatState():
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);case _:
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId,_that.hasMoreOld,_that.isLoadingOlder);case _:
throw StateError('Unexpected subclass');
}
@@ -196,10 +198,10 @@ return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder)? $default,) {final _that = this;
switch (_that) {
case _ChatState() when $default != null:
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);case _:
return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId,_that.hasMoreOld,_that.isLoadingOlder);case _:
return null;
}
@@ -211,12 +213,14 @@ return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);
@JsonSerializable()
class _ChatState implements ChatState {
const _ChatState({this.currentToken = '', this.chatResponse, this.referenceMessageId});
const _ChatState({this.currentToken = '', this.chatResponse, this.referenceMessageId, this.hasMoreOld = true, this.isLoadingOlder = false});
factory _ChatState.fromJson(Map<String, dynamic> json) => _$ChatStateFromJson(json);
@override@JsonKey() final String currentToken;
@override final GetChatResponse? chatResponse;
@override final int? referenceMessageId;
@override@JsonKey() final bool hasMoreOld;
@override@JsonKey() final bool isLoadingOlder;
/// Create a copy of ChatState
/// with the given fields replaced by the non-null parameter values.
@@ -231,16 +235,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId)&&(identical(other.hasMoreOld, hasMoreOld) || other.hasMoreOld == hasMoreOld)&&(identical(other.isLoadingOlder, isLoadingOlder) || other.isLoadingOlder == isLoadingOlder));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId);
int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId,hasMoreOld,isLoadingOlder);
@override
String toString() {
return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId)';
return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId, hasMoreOld: $hasMoreOld, isLoadingOlder: $isLoadingOlder)';
}
@@ -251,7 +255,7 @@ abstract mixin class _$ChatStateCopyWith<$Res> implements $ChatStateCopyWith<$Re
factory _$ChatStateCopyWith(_ChatState value, $Res Function(_ChatState) _then) = __$ChatStateCopyWithImpl;
@override @useResult
$Res call({
String currentToken, GetChatResponse? chatResponse, int? referenceMessageId
String currentToken, GetChatResponse? chatResponse, int? referenceMessageId, bool hasMoreOld, bool isLoadingOlder
});
@@ -268,12 +272,14 @@ class __$ChatStateCopyWithImpl<$Res>
/// Create a copy of ChatState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,Object? hasMoreOld = null,Object? isLoadingOlder = null,}) {
return _then(_ChatState(
currentToken: null == currentToken ? _self.currentToken : currentToken // ignore: cast_nullable_to_non_nullable
as String,chatResponse: freezed == chatResponse ? _self.chatResponse : chatResponse // ignore: cast_nullable_to_non_nullable
as GetChatResponse?,referenceMessageId: freezed == referenceMessageId ? _self.referenceMessageId : referenceMessageId // ignore: cast_nullable_to_non_nullable
as int?,
as int?,hasMoreOld: null == hasMoreOld ? _self.hasMoreOld : hasMoreOld // ignore: cast_nullable_to_non_nullable
as bool,isLoadingOlder: null == isLoadingOlder ? _self.isLoadingOlder : isLoadingOlder // ignore: cast_nullable_to_non_nullable
as bool,
));
}
@@ -12,6 +12,8 @@ _ChatState _$ChatStateFromJson(Map<String, dynamic> json) => _ChatState(
? null
: GetChatResponse.fromJson(json['chatResponse'] as Map<String, dynamic>),
referenceMessageId: (json['referenceMessageId'] as num?)?.toInt(),
hasMoreOld: json['hasMoreOld'] as bool? ?? true,
isLoadingOlder: json['isLoadingOlder'] as bool? ?? false,
);
Map<String, dynamic> _$ChatStateToJson(_ChatState instance) =>
@@ -19,4 +21,6 @@ Map<String, dynamic> _$ChatStateToJson(_ChatState instance) =>
'currentToken': instance.currentToken,
'chatResponse': instance.chatResponse,
'referenceMessageId': instance.referenceMessageId,
'hasMoreOld': instance.hasMoreOld,
'isLoadingOlder': instance.isLoadingOlder,
};