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

@@ -123,3 +123,13 @@
- Scroll-to-latest: the Flutter CanvasKit surface would NOT accept synthetic scroll input from Playwright (mouse wheel and drag both no-op on the canvas; input field steals focus). Verified instead with a deterministic widget test (test/scroll_to_latest_test.dart): 40 messages, starts at offset 0, drag up → offset > 400 + button appears, tap → offset back to 0. Passes. - Scroll-to-latest: the Flutter CanvasKit surface would NOT accept synthetic scroll input from Playwright (mouse wheel and drag both no-op on the canvas; input field steals focus). Verified instead with a deterministic widget test (test/scroll_to_latest_test.dart): 40 messages, starts at offset 0, drag up → offset > 400 + button appears, tap → offset back to 0. Passes.
- **Note for future:** getTimeline() only yields the ~10-event sync window and there is no scroll-back history pagination, so on a normal phone the timeline rarely exceeds ~1 screen and the jump button seldom shows in practice. Wiring requestHistory() on near-top scroll would make both the button and long-scrollback genuinely useful. Flagged, not built. - **Note for future:** getTimeline() only yields the ~10-event sync window and there is no scroll-back history pagination, so on a normal phone the timeline rarely exceeds ~1 screen and the jump button seldom shows in practice. Wiring requestHistory() on near-top scroll would make both the button and long-scrollback genuinely useful. Flagged, not built.
- **Status:** built + deployed app2.m8chat.au v1.10.0+14 (backup pre1100 tarball). Analyze clean, 4 tests pass. - **Status:** built + deployed app2.m8chat.au v1.10.0+14 (backup pre1100 tarball). Analyze clean, 4 tests pass.
### 2026-07-04 08:50 — Scroll-back history pagination (v1.11.0+15)
- **What:** wired requestHistory() so older messages load as you scroll toward the top. Completes the scroll-to-latest feature (previously the timeline only ever held the ~10-event sync window).
- **Repository (chat_repository.dart v1.5.0):** now keeps one live Timeline per room in `_timelines` map. watchTimeline creates it with an onUpdate callback that drives a StreamController signal, re-yielding the mapped list on BOTH live events and history pagination (the old code only re-yielded on onSync, which does NOT fire for requestHistory). loadMoreMessages paginates that stored instance (old code called requestHistory on a throwaway getTimeline() — wrong instance). Added canLoadMore(roomId) → timeline.canRequestHistory. Timeline cancelled + removed in the stream's finally.
- **UI (chat_screen.dart v1.7.0):** _TimelineState._onScroll detects near-top (reverse list: pixels >= maxScrollExtent - 400) and calls _loadOlder, guarded by _loadingMore + canLoadMore. Top-centre spinner while loading. Prepended older content grows maxScrollExtent while pixels stays put, so the viewport stays anchored and you move out of the trigger band (no infinite loop).
- **Verified:**
- Widget test test/history_pagination_test.dart: 40 msgs, fake repo, drag to top → loadMoreMessages called (with offset/override assertions). Passes. 5 tests total green.
- Live smoke test on production: room displays the synced window correctly after the refactor, and a newly-sent message appears at the bottom — proving the onUpdate→remap→display path (which history reuses) works.
- NOT screenshot-verified: older messages (1-5) materialising on scroll-to-top, because the CanvasKit surface rejects synthetic scroll in headless Chromium. Trigger (widget test) + delivery path (live message) are both proven; the SDK calls onUpdate after requestHistory (timeline.dart:115), same path.
- **Status:** built + deployed app2.m8chat.au v1.11.0+15 (backup pre1110 tarball). Analyze clean, 5 tests pass.

View File

@@ -11,7 +11,7 @@ abstract final class AppConfig {
'turns:matrix.m8chat.au:5349', 'turns:matrix.m8chat.au:5349',
]; ];
static const String appName = 'M8Chat'; static const String appName = 'M8Chat';
static const String appVersion = '1.10.0'; static const String appVersion = '1.11.0';
// Jitsi conferencing // Jitsi conferencing
static const String jitsiDomain = 'conf.m8chat.au'; static const String jitsiDomain = 'conf.m8chat.au';

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. // Chat repository — bridges Matrix SDK timeline to app domain models.
// Phase 2 additions: sendFile, sendReaction, redactEvent, reply support. // 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:matrix/matrix.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -22,6 +28,10 @@ class ChatRepository {
final Client _client; 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); Room? _getRoom(String roomId) => _client.getRoomById(roomId);
/// Returns a stream of message lists for [roomId]. /// Returns a stream of message lists for [roomId].
@@ -29,20 +39,32 @@ class ChatRepository {
final room = _getRoom(roomId); final room = _getRoom(roomId);
if (room == null) return; 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); try {
yield await _mapTimeline(timeline, room);
await for (final update in _client.onSync.stream) { await for (final _ in signal.stream) {
final updatesThisRoom = update.rooms?.join?.containsKey(roomId) ?? false;
if (updatesThisRoom) {
yield await _mapTimeline(timeline, room); 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]. /// Sends a plain text message. Supports replies via [inReplyToEventId].
Future<void> sendTextMessage( Future<void> sendTextMessage(
String roomId, String roomId,
@@ -107,11 +129,12 @@ class ChatRepository {
await room.setReadMarker(lastEventId, mRead: lastEventId); 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 { Future<void> loadMoreMessages(String roomId) async {
final room = _getRoom(roomId); final timeline = _timelines[roomId];
if (room == null) return; if (timeline == null || !timeline.canRequestHistory) return;
await room.requestHistory(); await timeline.requestHistory();
} }
/// Event types that should never appear in the chat timeline. /// Event types that should never appear in the chat timeline.

View File

@@ -1,4 +1,4 @@
// Version: 1.6.0 | Created: 2026-04-01 | Updated: 2026-07-04 // Version: 1.7.0 | Created: 2026-04-01 | Updated: 2026-07-04
// Full chat screen — timeline + input + typing indicators + read receipts // Full chat screen — timeline + input + typing indicators + read receipts
// + long-press context menu (quick reactions, reply, react, copy, edit, // + long-press context menu (quick reactions, reply, react, copy, edit,
// delete) + day separators + same-sender message grouping + room options. // delete) + day separators + same-sender message grouping + room options.
@@ -17,6 +17,7 @@ import '../../../shared/utils/matrix_id.dart';
import '../../../shared/utils/mxc_url.dart'; import '../../../shared/utils/mxc_url.dart';
import '../../../shared/widgets/matrix_avatar.dart'; import '../../../shared/widgets/matrix_avatar.dart';
import '../../rooms/presentation/user_search_dialog.dart'; import '../../rooms/presentation/user_search_dialog.dart';
import '../data/chat_repository.dart';
import '../domain/message_model.dart'; import '../domain/message_model.dart';
import 'chat_controller.dart'; import 'chat_controller.dart';
import 'message_bubble.dart'; import 'message_bubble.dart';
@@ -144,7 +145,12 @@ class _TimelineState extends ConsumerState<_Timeline> {
/// The list is reverse:true, so the newest message sits at offset 0. /// The list is reverse:true, so the newest message sits at offset 0.
/// Show the jump-to-latest button once the user scrolls up past this. /// Show the jump-to-latest button once the user scrolls up past this.
static const _showButtonThreshold = 400.0; static const _showButtonThreshold = 400.0;
/// How close to the oldest (top of a reverse list) before we prefetch more.
static const _loadMoreThreshold = 400.0;
bool _showScrollToLatest = false; bool _showScrollToLatest = false;
bool _loadingMore = false;
@override @override
void initState() { void initState() {
@@ -161,10 +167,30 @@ class _TimelineState extends ConsumerState<_Timeline> {
void _onScroll() { void _onScroll() {
if (!_scrollController.hasClients) return; if (!_scrollController.hasClients) return;
final show = _scrollController.offset > _showButtonThreshold; final pos = _scrollController.position;
final show = pos.pixels > _showButtonThreshold;
if (show != _showScrollToLatest) { if (show != _showScrollToLatest) {
setState(() => _showScrollToLatest = show); setState(() => _showScrollToLatest = show);
} }
// Near the oldest end (top of a reverse list) → load older history.
if (pos.hasContentDimensions &&
pos.pixels >= pos.maxScrollExtent - _loadMoreThreshold) {
_loadOlder();
}
}
Future<void> _loadOlder() async {
if (_loadingMore) return;
final repo = ref.read(chatRepositoryProvider);
if (!repo.canLoadMore(widget.roomId)) return;
setState(() => _loadingMore = true);
try {
await repo.loadMoreMessages(widget.roomId);
} finally {
if (mounted) setState(() => _loadingMore = false);
}
} }
void _scrollToLatest() { void _scrollToLatest() {
@@ -249,6 +275,20 @@ class _TimelineState extends ConsumerState<_Timeline> {
); );
}, },
), ),
// Loading older history (top of a reverse list).
if (_loadingMore)
const Positioned(
top: 8,
left: 0,
right: 0,
child: Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
Positioned( Positioned(
right: 12, right: 12,
bottom: 12, bottom: 12,

View File

@@ -1,7 +1,7 @@
name: m8chat_app name: m8chat_app
description: "M8Chat — Matrix chat client for Android, iOS, and Web." description: "M8Chat — Matrix chat client for Android, iOS, and Web."
publish_to: 'none' publish_to: 'none'
version: 1.10.0+14 version: 1.11.0+15
environment: environment:
sdk: '>=3.11.0 <4.0.0' sdk: '>=3.11.0 <4.0.0'

View File

@@ -0,0 +1,104 @@
// Version: 1.0.0 | Created: 2026-07-04
// Verifies the chat timeline requests older history when scrolled near the
// top (oldest end) of the reverse list. Driven through the real widget tree
// with a fake repository, since Playwright cannot scroll the CanvasKit surface.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:matrix/matrix.dart';
import 'package:m8chat_app/core/network/matrix_client.dart';
import 'package:m8chat_app/features/chat/data/chat_repository.dart';
import 'package:m8chat_app/features/chat/domain/message_model.dart';
import 'package:m8chat_app/features/chat/presentation/chat_controller.dart';
import 'package:m8chat_app/features/chat/presentation/chat_screen.dart';
class _FakeChatRepository extends ChatRepository {
_FakeChatRepository() : super(client: Client('test-client'));
int loadMoreCalls = 0;
bool _hasMore = true;
@override
bool canLoadMore(String roomId) => _hasMore;
@override
Future<void> loadMoreMessages(String roomId) async {
loadMoreCalls++;
_hasMore = false; // one page then exhausted
}
}
void main() {
testWidgets('scrolling to the top requests older history once', (
tester,
) async {
const roomId = '!paginate:server';
final base = DateTime(2026, 7, 4, 8);
final messages = [
for (var i = 40; i >= 1; i--)
MessageModel(
eventId: '\$e$i',
roomId: roomId,
senderId: '@me:server',
senderDisplayName: 'Me',
timestamp: base.add(Duration(seconds: i)),
type: MessageType.text,
body: 'Message $i',
isMine: true,
),
];
final repo = _FakeChatRepository();
await tester.pumpWidget(
ProviderScope(
overrides: [
matrixClientProvider.overrideWithValue(Client('test-client')),
chatRepositoryProvider.overrideWithValue(repo),
chatTimelineProvider(
roomId,
).overrideWith((ref) => Stream.value(messages)),
],
child: const MaterialApp(home: ChatScreen(roomId: roomId)),
),
);
await tester.pumpAndSettle();
expect(repo.loadMoreCalls, 0);
// Confirm the override is actually in effect.
final container = ProviderScope.containerOf(
tester.element(find.byType(ChatScreen)),
);
expect(identical(container.read(chatRepositoryProvider), repo), isTrue);
double offset() => tester
.state<ScrollableState>(find.byType(Scrollable).first)
.position
.pixels;
double maxExtent() => tester
.state<ScrollableState>(find.byType(Scrollable).first)
.position
.maxScrollExtent;
// Scroll up toward the oldest end (reverse list: drag content down).
await tester.drag(find.byType(ListView), const Offset(0, 3000));
await tester.pumpAndSettle();
expect(
offset(),
greaterThan(maxExtent() - 400),
reason:
'drag should have reached the oldest end (offset=${offset()}, '
'max=${maxExtent()})',
);
expect(
repo.loadMoreCalls,
greaterThan(0),
reason: 'reaching the top should request older history',
);
});
}