feat(chat): load older messages on scroll-to-top (history pagination) (v1.11.0+15)

Repository keeps one live Timeline per room and re-yields on its
onUpdate callback (fires for both live events AND requestHistory),
not just onSync which never fires for pagination. loadMoreMessages
now paginates that same instance; added canLoadMore.

Timeline widget requests older history when scrolled within 400px of
the oldest end, guarded against re-entry, with a top loading spinner.

Verified: widget test drives a drag-to-top and asserts loadMoreMessages
fires; live smoke test confirms the onUpdate delivery path (reused by
history) still shows new messages after the refactor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 08:44:38 +10:00
parent f9c21fddc4
commit e4aa5277d7
6 changed files with 194 additions and 17 deletions

View File

@@ -1,6 +1,12 @@
// Version: 1.4.0 | Created: 2026-04-01 | Updated: 2026-07-04
// Version: 1.5.0 | Created: 2026-04-01 | Updated: 2026-07-04
// Chat repository — bridges Matrix SDK timeline to app domain models.
// Phase 2 additions: sendFile, sendReaction, redactEvent, reply support.
// v1.5.0: keep one live Timeline per room so scroll-back history pagination
// (requestHistory) acts on the same instance the stream is watching, and
// re-yield on the timeline's own onUpdate (fires for both live events and
// history) rather than only on onSync (which does not fire for pagination).
import 'dart:async';
import 'package:matrix/matrix.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -22,6 +28,10 @@ class ChatRepository {
final Client _client;
/// Live timelines keyed by room id, so [loadMoreMessages] paginates the
/// same instance the active [watchTimeline] stream is mapping.
final Map<String, Timeline> _timelines = {};
Room? _getRoom(String roomId) => _client.getRoomById(roomId);
/// Returns a stream of message lists for [roomId].
@@ -29,20 +39,32 @@ class ChatRepository {
final room = _getRoom(roomId);
if (room == null) return;
final timeline = await room.getTimeline();
final signal = StreamController<void>();
final timeline = await room.getTimeline(
onUpdate: () {
if (!signal.isClosed) signal.add(null);
},
);
_timelines[roomId] = timeline;
yield await _mapTimeline(timeline, room);
await for (final update in _client.onSync.stream) {
final updatesThisRoom = update.rooms?.join?.containsKey(roomId) ?? false;
if (updatesThisRoom) {
try {
yield await _mapTimeline(timeline, room);
await for (final _ in signal.stream) {
yield await _mapTimeline(timeline, room);
}
} finally {
timeline.cancelSubscriptions();
if (identical(_timelines[roomId], timeline)) {
_timelines.remove(roomId);
}
await signal.close();
}
timeline.cancelSubscriptions();
}
/// Whether [roomId] has older messages that can still be paginated in.
bool canLoadMore(String roomId) =>
_timelines[roomId]?.canRequestHistory ?? false;
/// Sends a plain text message. Supports replies via [inReplyToEventId].
Future<void> sendTextMessage(
String roomId,
@@ -107,11 +129,12 @@ class ChatRepository {
await room.setReadMarker(lastEventId, mRead: lastEventId);
}
/// Requests older messages (pagination).
/// Requests older messages (pagination) on the active timeline for [roomId].
/// No-op if there is no live timeline or no more history to fetch.
Future<void> loadMoreMessages(String roomId) async {
final room = _getRoom(roomId);
if (room == null) return;
await room.requestHistory();
final timeline = _timelines[roomId];
if (timeline == null || !timeline.canRequestHistory) return;
await timeline.requestHistory();
}
/// Event types that should never appear in the chat timeline.