From f3fc6d76095441125f93ece0f5c7b267c66b9b75 Mon Sep 17 00:00:00 2001 From: Brian Charbonneau Date: Mon, 10 Aug 2026 13:25:52 -0700 Subject: [PATCH] fix(mobile): preserve channel scroll anchor on updates Signed-off-by: Brian Charbonneau --- .../channel_detail_page/message_list.dart | 66 ++++++++++++++++++- .../channels/channel_detail_page_test.dart | 28 +++++++- 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index eacba5856e..5da4121a8c 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -27,6 +27,7 @@ class _MessageList extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final displayEntries = groupMembershipTimelineEntries(entries); final itemScrollController = useMemoized(ItemScrollController.new); + final scrollOffsetController = useMemoized(ScrollOffsetController.new); final itemPositionsListener = useMemoized(ItemPositionsListener.create); final isLoadingOlder = useState(false); final isAtLatest = useState(true); @@ -40,6 +41,13 @@ class _MessageList extends HookConsumerWidget { final previousLatestEntryId = useRef(null); final didOpenInitialThread = useRef(false); final didJumpToInitialMessage = useRef(false); + final reversedEntryIds = [ + for (final group in displayEntries.reversed) group.first.message.id, + ]; + final visibleEntryPositions = useRef({}); + final pendingAnchorRestore = useRef<({String id, double leadingEdge})?>( + null, + ); int? reversedIndexOf(String? messageId) { if (messageId == null) return null; @@ -96,6 +104,38 @@ class _MessageList extends HookConsumerWidget { void onPositionsChanged() { final positions = itemPositionsListener.itemPositions.value; if (positions.isEmpty) return; + visibleEntryPositions.value = { + for (final position in positions) + if (position.index < reversedEntryIds.length) + reversedEntryIds[position.index]: position, + }; + final pendingAnchor = pendingAnchorRestore.value; + if (pendingAnchor != null && + (followsLatest.value || latestIsAtBoundary())) { + pendingAnchorRestore.value = null; + } else if (pendingAnchor != null) { + final currentAnchor = visibleEntryPositions.value[pendingAnchor.id]; + final viewportExtent = context.size?.height; + if (currentAnchor != null && + viewportExtent != null && + viewportExtent > 0) { + pendingAnchorRestore.value = null; + final offset = + (currentAnchor.itemLeadingEdge - pendingAnchor.leadingEdge) * + viewportExtent; + if (offset.abs() >= 0.5) { + // The reversed list retains its numeric index when index 0 is + // prepended, which shifts the user's visible message. Restore + // the stable message ID by exactly the measured viewport delta. + unawaited( + scrollOffsetController.animateScroll( + offset: offset, + duration: const Duration(milliseconds: 1), + ), + ); + } + } + } final nextIsAtLatest = latestIsAtBoundary(); if (nextIsAtLatest) { if (!isAtLatest.value) isAtLatest.value = true; @@ -179,10 +219,31 @@ class _MessageList extends HookConsumerWidget { previousLatestEntryId.value = latestEntryId; if (previous == null || latestEntryId == null || - previous == latestEntryId || - !isAtLatest.value) { + previous == latestEntryId) { return null; } + + if (!isAtLatest.value) { + MapEntry? anchor; + for (final entry in visibleEntryPositions.value.entries) { + final position = entry.value; + if (position.itemLeadingEdge < 0 || + position.itemLeadingEdge >= 1 || + (anchor != null && position.index >= anchor.value.index)) { + continue; + } + anchor = entry; + } + if (anchor == null) return null; + + if (!reversedEntryIds.contains(anchor.key)) return null; + pendingAnchorRestore.value = ( + id: anchor.key, + leadingEdge: anchor.value.itemLeadingEdge, + ); + return null; + } + WidgetsBinding.instance.addPostFrameCallback((_) { if (context.mounted) scrollToLatest(); }); @@ -250,6 +311,7 @@ class _MessageList extends HookConsumerWidget { child: ScrollablePositionedList.builder( key: const ValueKey('channel-message-list'), itemScrollController: itemScrollController, + scrollOffsetController: scrollOffsetController, itemPositionsListener: itemPositionsListener, reverse: true, padding: EdgeInsets.only( diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index f95c6fefed..c004efcab1 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1184,6 +1184,9 @@ void main() { find.byKey(const ValueKey('channel-jump-to-latest')), findsOneWidget, ); + final tallMessageRectBeforeUpdate = tester.getRect( + findRichText('Newest message line 0'), + ); messagesNotifier.setMessages([ ...initialMessages, @@ -1196,11 +1199,34 @@ void main() { ]); await tester.pumpAndSettle(); - expect(findRichText('Newest live update'), findsNothing); + final newestLiveUpdate = findRichText('Newest live update'); + expect(newestLiveUpdate.hitTestable(), findsNothing); + final tallMessageRectAfterUpdate = tester.getRect( + findRichText('Newest message line 0'), + ); + expect( + tallMessageRectAfterUpdate.top, + closeTo(tallMessageRectBeforeUpdate.top, 1), + ); + expect( + tallMessageRectAfterUpdate.bottom, + closeTo(tallMessageRectBeforeUpdate.bottom, 1), + ); expect( find.byKey(const ValueKey('channel-jump-to-latest')), findsOneWidget, ); + await tester.tap(find.byKey(const ValueKey('channel-jump-to-latest'))); + await tester.pumpAndSettle(); + + expect( + findRichText('Newest live update').hitTestable(), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsNothing, + ); }, );