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 ca909df722..37082ab963 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -45,7 +45,7 @@ class _MessageList extends HookConsumerWidget { initialMessageId == null && initialThreadRootId == null, ); final isAutoScrolling = useRef(false); - final autoScrollScheduled = useRef(false); + final latestRealignmentQueued = useRef(false); final latestEntryId = entries.isEmpty ? null : entries.last.message.id; final previousLatestEntryId = useRef(null); final didOpenInitialThread = useRef(false); @@ -198,18 +198,6 @@ class _MessageList extends HookConsumerWidget { } } - void scheduleAutoScrollToLatest() { - if (autoScrollScheduled.value || isAutoScrolling.value) return; - autoScrollScheduled.value = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - autoScrollScheduled.value = false; - if (!context.mounted || !followsLatest.value || hasUserScrolled.value) { - return; - } - scrollToLatest(); - }); - } - bool latestIsAtBoundary() { // In this reversed list, item 0's leading edge is the bottom boundary. // Being merely visible is not enough: a user who has pulled a tall @@ -220,6 +208,29 @@ class _MessageList extends HookConsumerWidget { ); } + void realignLatestAfterLayoutChange() { + if (latestRealignmentQueued.value || + !followsLatest.value || + hasUserScrolled.value) { + return; + } + latestRealignmentQueued.value = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + latestRealignmentQueued.value = false; + if (!context.mounted || + !itemScrollController.isAttached || + !followsLatest.value || + hasUserScrolled.value || + latestIsAtBoundary()) { + return; + } + // A dock or keyboard resize is a layout correction, not a navigation + // action. Keeping it instant avoids restarting a smooth scroll for + // every position report while the viewport settles. + itemScrollController.jumpTo(index: 0); + }); + } + useEffect(() { void onPositionsChanged() { final positions = itemPositionsListener.itemPositions.value; @@ -232,12 +243,7 @@ class _MessageList extends HookConsumerWidget { } if (nextIsAtLatest) { if (!isAtLatest.value) isAtLatest.value = true; - } else if (followsLatest.value && !hasUserScrolled.value) { - // The viewport can shrink when the composer or keyboard opens. - // Preserve auto-follow until the user scrolls the timeline. - if (!isAtLatest.value) isAtLatest.value = true; - scheduleAutoScrollToLatest(); - } else if (isAtLatest.value) { + } else if (!followsLatest.value && isAtLatest.value) { isAtLatest.value = false; } @@ -261,6 +267,22 @@ class _MessageList extends HookConsumerWidget { ); }, [channelId, entries.length, itemPositionsListener]); + // Composer size changes and keyboard metrics changes arrive in separate + // layout passes. Preserve the latest-message anchor for both, but only + // while the user has not deliberately left the tail. + useEffect(() { + realignLatestAfterLayoutChange(); + return null; + }, [composerBottomInset]); + + useEffect(() { + final observer = _ChannelLatestMetricsObserver( + onMetricsChanged: realignLatestAfterLayoutChange, + ); + WidgetsBinding.instance.addObserver(observer); + return () => WidgetsBinding.instance.removeObserver(observer); + }, [itemScrollController]); + useEffect(() { if (initialThreadRootId == null || didOpenInitialThread.value) { return null; @@ -588,3 +610,12 @@ class _JumpToLatestButton extends StatelessWidget { ); } } + +class _ChannelLatestMetricsObserver with WidgetsBindingObserver { + final VoidCallback onMetricsChanged; + + _ChannelLatestMetricsObserver({required this.onMetricsChanged}); + + @override + void didChangeMetrics() => onMetricsChanged(); +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 912233aba0..c2ae21e05d 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1465,6 +1465,16 @@ void main() { ); await tester.pumpAndSettle(); + // History requests are deliberately scheduled after the current frame. + // Advance the test clock until the capped chain has settled. + for ( + var frame = 0; + frame < 8 && messagesNotifier.fetchOlderCalls < 4; + frame++ + ) { + await tester.pump(const Duration(milliseconds: 1)); + } + expect(messagesNotifier.fetchOlderCalls, 4); final unreadButton = find.byKey( const ValueKey('channel-jump-to-oldest-unread'), @@ -1758,6 +1768,143 @@ void main() { ); }); + testWidgets( + 'keeps the followed tail anchored through composer and keyboard resize', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.reset); + + final messages = [ + for (var i = 0; i < 20; i++) + _textMsg( + id: 'msg$i', + pubkey: i.isEven ? 'alice' : 'bob', + content: 'Message $i', + createdAt: 1000 + i * 1000, + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: messages, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + final latestMessage = find.byKey( + const ValueKey('channel-message-group-msg19'), + ); + final composerDock = find.byKey( + const ValueKey('channel-composer-dock'), + ); + final compactDockHeight = tester.getSize(composerDock).height; + + expect(latestMessage, findsOneWidget); + expect( + tester.getBottomLeft(latestMessage).dy, + lessThanOrEqualTo(tester.getTopLeft(composerDock).dy + 1), + ); + + await tester.tap(find.text('Message #general')); + await tester.pumpAndSettle(); + + expect( + tester.getSize(composerDock).height, + greaterThan(compactDockHeight), + ); + expect( + tester.getBottomLeft(latestMessage).dy, + lessThanOrEqualTo(tester.getTopLeft(composerDock).dy + 1), + ); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsNothing, + ); + + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pumpAndSettle(); + + expect(latestMessage, findsOneWidget); + expect( + tester.getBottomLeft(latestMessage).dy, + lessThanOrEqualTo(tester.getTopLeft(composerDock).dy + 1), + ); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsNothing, + ); + }, + ); + + testWidgets( + 'does not realign a user-detached timeline on keyboard resize', + (tester) async { + tester.view.physicalSize = const Size(400, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.reset); + + final messages = [ + for (var i = 0; i < 40; i++) + _textMsg( + id: 'msg$i', + pubkey: 'alice', + content: 'Message $i', + createdAt: 1000 + i, + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: messages, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + final messageList = find.byKey(const ValueKey('channel-message-list')); + await tester.drag(messageList, const Offset(0, 300)); + await tester.pumpAndSettle(); + + expect(findRichText('Message 39'), findsNothing); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsOneWidget, + ); + + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pumpAndSettle(); + + expect(findRichText('Message 39'), findsNothing); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsOneWidget, + ); + final positions = tester + .widget(messageList) + .itemPositionsNotifier! + .itemPositions + .value; + expect( + positions.any( + (position) => + position.index == 0 && position.itemLeadingEdge.abs() < 0.01, + ), + isFalse, + ); + }, + ); + testWidgets('can jump back to latest after a non-drag user scroll', ( tester, ) async {