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,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,