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:
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<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() {
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user