diff --git a/help4bis-claude-notes/decisions.md b/help4bis-claude-notes/decisions.md index 169308b..d3b6c08 100644 --- a/help4bis-claude-notes/decisions.md +++ b/help4bis-claude-notes/decisions.md @@ -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. - **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. + +### 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. diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index b49835a..a9583e6 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -11,7 +11,7 @@ abstract final class AppConfig { 'turns:matrix.m8chat.au:5349', ]; static const String appName = 'M8Chat'; - static const String appVersion = '1.10.0'; + static const String appVersion = '1.11.0'; // Jitsi conferencing static const String jitsiDomain = 'conf.m8chat.au'; diff --git a/lib/features/chat/data/chat_repository.dart b/lib/features/chat/data/chat_repository.dart index 26d8bdd..b38380b 100644 --- a/lib/features/chat/data/chat_repository.dart +++ b/lib/features/chat/data/chat_repository.dart @@ -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 _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(); + 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 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 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. diff --git a/lib/features/chat/presentation/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index 4bc4999..f0b3dcf 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -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 // + long-press context menu (quick reactions, reply, react, copy, edit, // 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/widgets/matrix_avatar.dart'; import '../../rooms/presentation/user_search_dialog.dart'; +import '../data/chat_repository.dart'; import '../domain/message_model.dart'; import 'chat_controller.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. /// Show the jump-to-latest button once the user scrolls up past this. 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 _loadingMore = false; @override void initState() { @@ -161,10 +167,30 @@ class _TimelineState extends ConsumerState<_Timeline> { void _onScroll() { if (!_scrollController.hasClients) return; - final show = _scrollController.offset > _showButtonThreshold; + final pos = _scrollController.position; + + final show = pos.pixels > _showButtonThreshold; if (show != _showScrollToLatest) { 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 _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() { @@ -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( right: 12, bottom: 12, diff --git a/pubspec.yaml b/pubspec.yaml index 87bd4fe..d30956a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: m8chat_app description: "M8Chat — Matrix chat client for Android, iOS, and Web." publish_to: 'none' -version: 1.10.0+14 +version: 1.11.0+15 environment: sdk: '>=3.11.0 <4.0.0' diff --git a/test/history_pagination_test.dart b/test/history_pagination_test.dart new file mode 100644 index 0000000..7c9ca8c --- /dev/null +++ b/test/history_pagination_test.dart @@ -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 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(find.byType(Scrollable).first) + .position + .pixels; + double maxExtent() => tester + .state(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', + ); + }); +}