implemented backward pagination for chat history with on-scroll prefetching
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user