From f3cd7896d9c261828edb1d4e0dd82c3a49fecb50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Fri, 17 Jul 2026 20:14:47 +0200 Subject: [PATCH] implemented backward pagination for chat history with on-scroll prefetching --- .../talk/chat/get_chat_cache.dart | 5 +- .../talk/chat/get_chat_history.dart | 56 ++++++++ .../app/modules/chat/bloc/chat_bloc.dart | 122 +++++++++++++++++- .../app/modules/chat/bloc/chat_state.dart | 2 + .../modules/chat/bloc/chat_state.freezed.dart | 46 ++++--- .../app/modules/chat/bloc/chat_state.g.dart | 4 + lib/view/pages/talk/chat_view.dart | 75 ++++++++--- 7 files changed, 270 insertions(+), 40 deletions(-) create mode 100644 lib/api/marianumcloud/talk/chat/get_chat_history.dart diff --git a/lib/api/marianumcloud/talk/chat/get_chat_cache.dart b/lib/api/marianumcloud/talk/chat/get_chat_cache.dart index 01ee56a..1a99d58 100644 --- a/lib/api/marianumcloud/talk/chat/get_chat_cache.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_cache.dart @@ -16,7 +16,10 @@ class GetChatCache extends SimpleCache { GetChatParams( lookIntoFuture: GetChatParamsSwitch.off, setReadMarker: GetChatParamsSwitch.on, - limit: 200, + // Small initial page; also the per-chat offline snapshot written to + // localstore. Older messages are paged in on scroll-up via + // GetChatHistory. Keep in sync with ChatBloc's _kInitialPageSize. + limit: 50, ), ).run(), fromJson: GetChatResponse.fromJson, diff --git a/lib/api/marianumcloud/talk/chat/get_chat_history.dart b/lib/api/marianumcloud/talk/chat/get_chat_history.dart new file mode 100644 index 0000000..ab038dd --- /dev/null +++ b/lib/api/marianumcloud/talk/chat/get_chat_history.dart @@ -0,0 +1,56 @@ +import 'package:http/http.dart' as http; + +import '../../../errors/server_exception.dart'; +import '../../../http_errors.dart'; +import '../../nextcloud_ocs.dart'; +import 'get_chat_params.dart'; +import 'get_chat_response.dart'; + +/// Backwards-paging variant of GetChat (`lookIntoFuture=0` + `lastKnownMessageId`) +/// that fetches the page of messages *older* than a given id. Bypasses [TalkApi] +/// because that layer treats non-2xx as errors, and the server answers HTTP 304 +/// when there are no older messages left — a normal "start of chat" outcome here. +/// `setReadMarker=off` so paging into history never moves the read cursor. +class GetChatHistory { + final String chatToken; + final int lastKnownMessageId; + final int limit; + + GetChatHistory({ + required this.chatToken, + required this.lastKnownMessageId, + required this.limit, + }); + + /// Returns the older page, or `null` on HTTP 304 (no older messages). + Future run() async { + final params = GetChatParams( + lookIntoFuture: GetChatParamsSwitch.off, + lastKnownMessageId: lastKnownMessageId, + includeLastKnown: GetChatParamsSwitch.off, + setReadMarker: GetChatParamsSwitch.off, + limit: limit, + ); + final uri = NextcloudOcs.uri( + 'apps/spreed/api/v1/chat/$chatToken', + queryParameters: params.toJson(), + ); + final headers = NextcloudOcs.headers(); + + final response = (await sendGuarded( + 'GetChatHistory $uri', + () => http.get(uri, headers: headers), + ))!; + + final status = response.statusCode; + if (status == 304) return null; + if (status >= 200 && status < 300) { + return GetChatResponse.fromJson(NextcloudOcs.decode(response.body)) + ..headers = response.headers; + } + throw ServerException( + statusCode: status, + technicalDetails: 'GetChatHistory $uri: HTTP $status', + ); + } +} diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart index ca5a1f0..4bdd44a 100644 --- a/lib/state/app/modules/chat/bloc/chat_bloc.dart +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -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 json) => ChatState.fromJson(json); @override - Map? toStorage(ChatState state) => state.toJson(); + Map? 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 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()); _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 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 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 = {}; @@ -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 = {}; + 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; diff --git a/lib/state/app/modules/chat/bloc/chat_state.dart b/lib/state/app/modules/chat/bloc/chat_state.dart index f41438e..c52ce65 100644 --- a/lib/state/app/modules/chat/bloc/chat_state.dart +++ b/lib/state/app/modules/chat/bloc/chat_state.dart @@ -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 json) => diff --git a/lib/state/app/modules/chat/bloc/chat_state.freezed.dart b/lib/state/app/modules/chat/bloc/chat_state.freezed.dart index 0467823..e65134c 100644 --- a/lib/state/app/modules/chat/bloc/chat_state.freezed.dart +++ b/lib/state/app/modules/chat/bloc/chat_state.freezed.dart @@ -15,7 +15,7 @@ T _$identity(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 get copyWith => _$ChatStateCopyWithImpl @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 Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(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 Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId) $default,) {final _that = this; +@optionalTypeArgs TResult when(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? Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(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 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 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, )); } diff --git a/lib/state/app/modules/chat/bloc/chat_state.g.dart b/lib/state/app/modules/chat/bloc/chat_state.g.dart index b685b00..abf6a7c 100644 --- a/lib/state/app/modules/chat/bloc/chat_state.g.dart +++ b/lib/state/app/modules/chat/bloc/chat_state.g.dart @@ -12,6 +12,8 @@ _ChatState _$ChatStateFromJson(Map json) => _ChatState( ? null : GetChatResponse.fromJson(json['chatResponse'] as Map), referenceMessageId: (json['referenceMessageId'] as num?)?.toInt(), + hasMoreOld: json['hasMoreOld'] as bool? ?? true, + isLoadingOlder: json['isLoadingOlder'] as bool? ?? false, ); Map _$ChatStateToJson(_ChatState instance) => @@ -19,4 +21,6 @@ Map _$ChatStateToJson(_ChatState instance) => 'currentToken': instance.currentToken, 'chatResponse': instance.chatResponse, 'referenceMessageId': instance.referenceMessageId, + 'hasMoreOld': instance.hasMoreOld, + 'isLoadingOlder': instance.isLoadingOlder, }; diff --git a/lib/view/pages/talk/chat_view.dart b/lib/view/pages/talk/chat_view.dart index b11a7ad..f3a1ba9 100644 --- a/lib/view/pages/talk/chat_view.dart +++ b/lib/view/pages/talk/chat_view.dart @@ -42,9 +42,15 @@ class ChatView extends StatefulWidget { class _ChatViewState extends State with RouteAware { final ItemScrollController _itemScrollController = ItemScrollController(); + final ItemPositionsListener _positionsListener = + ItemPositionsListener.create(); final TextEditingController _searchTextController = TextEditingController(); final Map _matchIndices = {}; + // Number of rows currently rendered; kept in sync in build so the scroll + // listener can tell when the oldest row (highest index, reverse list) nears. + int _itemCount = 0; + bool _searchActive = false; String _searchQuery = ''; List _matches = const []; @@ -63,9 +69,24 @@ class _ChatViewState extends State with RouteAware { super.initState(); _chatBlocRef = context.read(); _chatListBlocRef = context.read(); + _positionsListener.itemPositions.addListener(_onScrollPositions); NotificationTasks.clearNotificationsForChat(widget.room.token); } + /// Loads the next older block once the top of the list comes into view. + void _onScrollPositions() { + final positions = _positionsListener.itemPositions.value; + if (positions.isEmpty) return; + // reverse:true → the highest index is the oldest message (top of screen). + // Prefetch ~a screenful ahead so the next block is usually already merged + // before the user reaches the top — the load stays invisible. + final maxIndex = positions.map((p) => p.index).reduce(math.max); + if (maxIndex < _itemCount - _kLoadOlderPrefetchRows) return; + final data = _chatBlocRef?.state.data; + if (data == null || !data.hasMoreOld || data.isLoadingOlder) return; + _chatBlocRef?.loadOlder(); + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -93,6 +114,7 @@ class _ChatViewState extends State with RouteAware { if (_subscribedRoute != null) { AppRoutes.chatRouteObserver.unsubscribe(this); } + _positionsListener.itemPositions.removeListener(_onScrollPositions); _markAsReadFinal(); _chatBlocRef?.leaveChat(widget.room.token); _searchTextController.dispose(); @@ -281,22 +303,6 @@ class _ChatViewState extends State with RouteAware { ); } - if (response.data.length >= 200) { - messages.insert( - 0, - ChatBubble( - isSender: false, - bubbleData: GetChatResponseObject.getTextDummy( - 'Zurzeit können in dieser App nur die letzten 200 vergangenen Nachrichten angezeigt werden. ' - 'Um ältere Nachrichten abzurufen verwende die Webversion unter https://cloud.marianum-fulda.de', - ), - chatData: widget.room, - refetch: ({bool renew = false}) => _refresh(), - ), - ); - chronologicalMatchIndex.updateAll((_, v) => v + 1); - } - final total = messages.length; _matchIndices ..clear() @@ -373,9 +379,27 @@ class _ChatViewState extends State with RouteAware { final items = _buildMessages( state.chatResponse!, ).reversed.toList(); + // reverse:true renders index 0 at the bottom, so the top + // marker (spinner / start-of-chat) goes at the end. + if (state.isLoadingOlder) { + items.add(const _LoadingOlderIndicator()); + } else if (!state.hasMoreOld) { + items.add( + ChatBubble( + isSender: false, + bubbleData: GetChatResponseObject.getTextDummy( + 'Anfang des Chats', + ), + chatData: widget.room, + refetch: ({bool renew = false}) => _refresh(), + ), + ); + } + _itemCount = items.length; return ScrollablePositionedList.builder( reverse: true, itemScrollController: _itemScrollController, + itemPositionsListener: _positionsListener, itemCount: items.length, itemBuilder: (ctx, idx) => items[idx], ); @@ -405,3 +429,22 @@ class _ChatViewState extends State with RouteAware { ); } } + +/// How many rows before the oldest one the older-history prefetch kicks in. +const _kLoadOlderPrefetchRows = 15; + +class _LoadingOlderIndicator extends StatelessWidget { + const _LoadingOlderIndicator(); + + @override + Widget build(BuildContext context) => const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); +}