From 7df8cf9f238d95731dc40065bea03f7367680fb8 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Tue, 25 Aug 2026 18:53:42 +0100 Subject: [PATCH 1/2] Fix mobile jump-to-latest flicker Signed-off-by: kenny lopez --- .../channel_detail_page/message_list.dart | 7 +- .../channels/jump_to_latest_button.dart | 2 + .../channels/jump_to_latest_switcher.dart | 32 +- .../channels/thread_detail_helpers.dart | 13 +- .../features/channels/thread_detail_page.dart | 38 ++- .../channels/channel_detail_page_test.dart | 300 +++++++++++++----- .../channels/jump_to_latest_button_test.dart | 122 +++++++ 7 files changed, 411 insertions(+), 103 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 807111ef369..90af149fe56 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -437,8 +437,13 @@ class _MessageList extends HookConsumerWidget { ) - composerBottomInset, ); + final hidesJumpToLatest = shouldHideChannelJumpToLatest( + isAtLatest: latestIsAtBoundary(), + followsLatest: followsLatest.value, + userHasDetached: hasUserScrolled.value, + ); final shouldShow = - !latestIsAtBoundary() && + !hidesJumpToLatest && (hasUnseenLatestEntry.value || !latestIsVisible || distanceFromLatest.value > visiblePageHeight); diff --git a/mobile/lib/features/channels/jump_to_latest_button.dart b/mobile/lib/features/channels/jump_to_latest_button.dart index 00f1ca65926..692d49472cd 100644 --- a/mobile/lib/features/channels/jump_to_latest_button.dart +++ b/mobile/lib/features/channels/jump_to_latest_button.dart @@ -33,6 +33,7 @@ class JumpToLatestButton extends HookWidget { final nativeChannel = useState(null); final onPressedRef = useRef(onPressed)..value = onPressed; final brightness = context.theme.brightness.name; + final routeIsCurrent = ModalRoute.isCurrentOf(context) ?? true; useEffect(() { final channel = nativeChannel.value; @@ -101,6 +102,7 @@ class JumpToLatestButton extends HookWidget { child: ValueListenableBuilder( valueListenable: messageActionBackdropActive, builder: (context, backdropActive, _) { + if (!routeIsCurrent) return const SizedBox.shrink(); if (!usesNativeIosGlass || backdropActive) { return buildFlutterSurface(); } diff --git a/mobile/lib/features/channels/jump_to_latest_switcher.dart b/mobile/lib/features/channels/jump_to_latest_switcher.dart index 916150dfda9..a7f6b4cdcfb 100644 --- a/mobile/lib/features/channels/jump_to_latest_switcher.dart +++ b/mobile/lib/features/channels/jump_to_latest_switcher.dart @@ -2,6 +2,17 @@ import 'package:flutter/material.dart'; import 'jump_to_latest_button.dart'; +/// Whether channel Latest should remain hidden at the effective timeline tail. +/// +/// Composer and keyboard layout updates can briefly make item-position +/// measurements stale. Preserve an active tail-follow intent through those +/// frames unless the user explicitly detached from the tail. +bool shouldHideChannelJumpToLatest({ + required bool isAtLatest, + required bool followsLatest, + required bool userHasDetached, +}) => isAtLatest || (followsLatest && !userHasDetached); + /// Shared channel/thread visibility transition for [JumpToLatestButton]. class JumpToLatestSwitcher extends StatelessWidget { final String id; @@ -18,6 +29,19 @@ class JumpToLatestSwitcher extends StatelessWidget { @override Widget build(BuildContext context) { final reduceMotion = MediaQuery.disableAnimationsOf(context); + final child = visible + ? JumpToLatestButton( + key: ValueKey('$id-jump-to-latest'), + id: id, + onPressed: onPressed, + ) + : SizedBox.shrink(key: ValueKey('$id-jump-to-latest-hidden')); + if (Theme.of(context).platform == TargetPlatform.iOS) { + return KeyedSubtree( + key: ValueKey('$id-jump-to-latest-switcher'), + child: child, + ); + } return AnimatedSwitcher( key: ValueKey('$id-jump-to-latest-switcher'), duration: reduceMotion @@ -36,13 +60,7 @@ class JumpToLatestSwitcher extends StatelessWidget { child: child, ), ), - child: visible - ? JumpToLatestButton( - key: ValueKey('$id-jump-to-latest'), - id: id, - onPressed: onPressed, - ) - : SizedBox.shrink(key: ValueKey('$id-jump-to-latest-hidden')), + child: child, ); } } diff --git a/mobile/lib/features/channels/thread_detail_helpers.dart b/mobile/lib/features/channels/thread_detail_helpers.dart index 9b535397659..4148fca4d1a 100644 --- a/mobile/lib/features/channels/thread_detail_helpers.dart +++ b/mobile/lib/features/channels/thread_detail_helpers.dart @@ -1,17 +1,20 @@ part of 'thread_detail_page.dart'; -/// Returns whether a bounded tail correction reached the effective end. +/// Returns whether the thread is at its effective scroll end. /// -/// Item positions can lag the active scroll position by a frame, so an exact -/// end-of-scroll measurement is sufficient even while the tail item still +/// Item positions can lag or briefly oscillate during lazy layout, so an exact +/// end-of-scroll measurement remains authoritative even while the tail item /// reports outside the visible boundary. @visibleForTesting -bool threadTailCorrectionReachedEnd({ +bool threadTailIsAtEffectiveEnd({ + required bool tailIsLaidOut, required bool tailIsVisible, required double? extentAfter, }) => tailIsVisible || - (extentAfter != null && extentAfter <= _threadTailScrollTolerance); + (tailIsLaidOut && + extentAfter != null && + extentAfter <= _threadTailScrollTolerance); int _threadTailIndex(int replyCount) => replyCount; diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index cbac3cff843..c29c6878a7d 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -252,9 +252,11 @@ class ThreadDetailPage extends HookConsumerWidget { final initialTailSettle = useMemoized(InitialThreadTailSettle.new); final isAtThreadTail = useState(true); final isNavigatingToThreadTail = useState(false); + final hidesLatestForComposerTailFollow = useState(false); final tailCorrectionInProgress = useRef(false); final tailCorrectionGeneration = useRef(0); final activeThreadScrollPosition = useRef(null); + final composerHasFocus = useListenable(composerFocusNode).hasFocus; final viewportHeight = useListenable(listViewport.height).value; final previousViewportHeight = useRef(viewportHeight); final settledImeLift = usesFixedAndroidImeViewport @@ -321,10 +323,21 @@ class ThreadDetailPage extends HookConsumerWidget { final trailingBoundary = 1 - ((Grid.xs + timelineBottomInset) / viewportHeight) + 0.001; final lastMessageIndex = _threadTailIndex(replies.length); - return itemPositionsListener.itemPositions.value.any( - (position) => - position.index == lastMessageIndex && - position.itemTrailingEdge <= trailingBoundary, + var tailItemIsLaidOut = false; + var tailItemIsVisible = false; + for (final item in itemPositionsListener.itemPositions.value) { + if (item.index != lastMessageIndex) continue; + tailItemIsLaidOut = true; + tailItemIsVisible = item.itemTrailingEdge <= trailingBoundary; + break; + } + final position = activeThreadScrollPosition.value; + return threadTailIsAtEffectiveEnd( + tailIsLaidOut: tailItemIsLaidOut, + tailIsVisible: tailItemIsVisible, + extentAfter: position != null && position.hasContentDimensions + ? position.extentAfter + : null, ); } @@ -387,7 +400,6 @@ class ThreadDetailPage extends HookConsumerWidget { final reachedTail = threadTailIsVisible(); // Lazy children can revise maxScrollExtent for several frames. Keep // moving the same active position until the measured tail is visible; - // the cap only guards pathological layouts that never stabilize. if (!reachedTail && corrections < _latestTailCorrectionLimit) { jumpActiveScrollPositionToTail(); WidgetsBinding.instance.addPostFrameCallback( @@ -403,17 +415,7 @@ class ThreadDetailPage extends HookConsumerWidget { tailCorrectionInProgress.value = false; isNavigatingToThreadTail.value = false; if (revealViewport) initialViewportReady.value = true; - // Item positions can trail the ScrollPosition by a frame after an - // animated jump. Once the bounded lazy-layout correction is exhausted, - // trust an exact end-of-scroll position too: there is nowhere further - // for Latest to navigate, so leaving the control visible is misleading. - final position = activeThreadScrollPosition.value; - isAtThreadTail.value = threadTailCorrectionReachedEnd( - tailIsVisible: reachedTail, - extentAfter: position != null && position.hasContentDimensions - ? position.extentAfter - : null, - ); + isAtThreadTail.value = reachedTail; } void correctThreadTailInstantly() { @@ -432,6 +434,7 @@ class ThreadDetailPage extends HookConsumerWidget { void followThreadTailFromComposer() { if (userDragDetachedTailFollow.value) return; + hidesLatestForComposerTailFollow.value = true; initialTailSettle.abandon(); initialViewportReady.value = true; tailIntent.endDrag(); @@ -863,6 +866,7 @@ class ThreadDetailPage extends HookConsumerWidget { child: _ThreadMessageList( viewport: listViewport, onUserScrollStart: () { + hidesLatestForComposerTailFollow.value = false; initialTailSettle.abandon(); initialViewportReady.value = true; tailCorrectionInProgress.value = false; @@ -981,6 +985,8 @@ class ThreadDetailPage extends HookConsumerWidget { threadViewportVisible && hasFetchedReplies && !isNavigatingToThreadTail.value && + !hidesLatestForComposerTailFollow.value && + !(composerHasFocus && !userDragDetachedTailFollow.value) && !isAtThreadTail.value, onPressed: scrollToThreadLatest, ), diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 08b7e570c8f..1533a135fb3 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -4129,6 +4129,16 @@ void main() { ); await tester.tap(find.text('Message #general')); + for (var frame = 0; frame < 15; frame += 1) { + await tester.pump(const Duration(milliseconds: 16)); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsNothing, + reason: + 'Composer expansion must not expose Latest while tail-follow ' + 'layout catches up.', + ); + } await tester.pumpAndSettle(); expect( @@ -4144,7 +4154,24 @@ void main() { findsNothing, ); - tester.view.viewInsets = const FakeViewPadding(bottom: 300); + for (final inset in const [80.0, 160.0, 240.0, 300.0]) { + tester.view.viewInsets = FakeViewPadding(bottom: inset); + await tester.pump(const Duration(milliseconds: 16)); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsNothing, + reason: + 'IME inset frames must not expose Latest while the followed ' + 'tail is being realigned.', + ); + } + await tester.pump(androidImeMetricsSettleDelay); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsNothing, + reason: + 'Latest must stay hidden when settled IME padding is applied.', + ); await tester.pumpAndSettle(); expect(latestMessage, findsOneWidget); @@ -10823,6 +10850,96 @@ void main() { ); }); + testWidgets( + 'iOS thread keeps Latest hidden through composer and keyboard frames', + (tester) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + try { + final rootEvent = _textMsg( + id: 'thread-root', + pubkey: 'alice', + content: 'A short thread', + createdAt: 1000, + ); + final replies = [ + for (var i = 0; i < 6; i++) + _textMsg( + id: 'reply-$i', + pubkey: i.isEven ? 'alice' : 'bob', + content: i.isEven ? 'hello' : 'testing', + createdAt: 1100 + i, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + threadReplies: {'thread-root': replies}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + final threadHead = formatTimeline([rootEvent]).single; + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: [threadHead], + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('thread-jump-to-latest')), + findsNothing, + ); + + await tester.tap(find.text('Reply in thread…').hitTestable()); + for (var frame = 0; frame < 15; frame += 1) { + await tester.pump(const Duration(milliseconds: 16)); + expect( + find.byKey(const ValueKey('thread-jump-to-latest')), + findsNothing, + reason: + 'Composer expansion must not expose Latest while followed ' + 'tail geometry catches up.', + ); + } + + for (final inset in const [80.0, 160.0, 240.0, 300.0]) { + tester.view.viewInsets = FakeViewPadding(bottom: inset); + await tester.pump(const Duration(milliseconds: 16)); + expect( + find.byKey(const ValueKey('thread-jump-to-latest')), + findsNothing, + reason: + 'IME inset frames must not expose Latest while the composer ' + 'is following the thread tail.', + ); + } + await tester.pumpAndSettle(); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }, + ); + for (final replyCount in [0, 1]) { testWidgets( 'cached writable $replyCount-reply thread defers dock correction until measured', @@ -11365,75 +11482,94 @@ void main() { testWidgets('short initial thread hydration remains top-anchored', ( tester, ) async { - final rootEvent = _textMsg( - id: 'thread-root', - pubkey: 'alice', - content: 'Thread root', - createdAt: 1000, - ); - final replies = [ - _textMsg( - id: 'reply-1', - pubkey: 'bob', - content: 'First reply', - createdAt: 1100, - extraTags: const [ - ['e', 'thread-root', '', 'reply'], - ], - ), - _textMsg( - id: 'reply-2', - pubkey: 'bob', - content: 'Second reply', - createdAt: 1101, - extraTags: const [ - ['e', 'thread-root', '', 'reply'], - ], - ), - ]; - final completer = Completer>(); + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + final rootEvent = _textMsg( + id: 'thread-root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final replies = [ + _textMsg( + id: 'reply-1', + pubkey: 'bob', + content: 'First reply', + createdAt: 1100, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + _textMsg( + id: 'reply-2', + pubkey: 'bob', + content: 'Second reply', + createdAt: 1101, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + ]; + final completer = Completer>(); - await tester.pumpWidget( - _buildTestable( - messages: [rootEvent], - pendingThreadReplies: {'thread-root': completer.future}, - users: const { - 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), - 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), - }, - ), - ); - await tester.pumpAndSettle(); + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + pendingThreadReplies: {'thread-root': completer.future}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); - final threadHead = formatTimeline([rootEvent]).single; - Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: threadHead, - allMessages: [threadHead], - channelId: _channelId, - currentPubkey: 'self', - isMember: true, - isArchived: false, + final threadHead = formatTimeline([rootEvent]).single; + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: [threadHead], + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), ), - ), - ); - await tester.pumpAndSettle(); + ); + await tester.pumpAndSettle(); - final headFinder = find.byKey( - const ValueKey('thread-message-group-thread-root'), - ); - final initialHeadY = tester.getTopLeft(headFinder).dy; + final headFinder = find.byKey( + const ValueKey('thread-message-group-thread-root'), + ); + final initialHeadY = tester.getTopLeft(headFinder).dy; + const latestButton = ValueKey('thread-jump-to-latest'); + expect(find.byKey(latestButton), findsNothing); - completer.complete(replies); - await tester.pumpAndSettle(); + completer.complete(replies); + await tester.pump(); + for (var frame = 0; frame < 8; frame++) { + expect( + find.byKey(latestButton), + findsNothing, + reason: + 'Ordinary thread entry must not expose Latest on frame $frame.', + ); + await tester.pump(); + } + await tester.pumpAndSettle(); - expect(headFinder, findsOneWidget); - expect( - find.byKey(const ValueKey('thread-message-group-reply-2')), - findsOneWidget, - ); - expect(tester.getTopLeft(headFinder).dy, closeTo(initialHeadY, 0.5)); + expect(headFinder, findsOneWidget); + expect( + find.byKey(const ValueKey('thread-message-group-reply-2')), + findsOneWidget, + ); + expect(tester.getTopLeft(headFinder).dy, closeTo(initialHeadY, 0.5)); + expect(find.byKey(latestButton), findsNothing); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } }); testWidgets( @@ -12983,19 +13119,35 @@ void main() { expect(find.byKey(const ValueKey('thread-jump-to-latest')), findsNothing); }); - test( - 'thread tail accepts exact scroll extent while item positions lag', - () { + test('thread tail ignores oscillating item positions at exact extent', () { + for (final tailItemIsVisible in [true, false, false, true, false]) { expect( - threadTailCorrectionReachedEnd(tailIsVisible: false, extentAfter: 0), + threadTailIsAtEffectiveEnd( + tailIsLaidOut: true, + tailIsVisible: tailItemIsVisible, + extentAfter: 0, + ), isTrue, ); - expect( - threadTailCorrectionReachedEnd(tailIsVisible: false, extentAfter: 1), - isFalse, - ); - }, - ); + } + expect( + threadTailIsAtEffectiveEnd( + tailIsLaidOut: true, + tailIsVisible: false, + extentAfter: 1, + ), + isFalse, + ); + expect( + threadTailIsAtEffectiveEnd( + tailIsLaidOut: false, + tailIsVisible: false, + extentAfter: 0, + ), + isFalse, + reason: 'A not-yet-laid-out lazy tail cannot trust stale extent.', + ); + }); testWidgets('thread Latest settles across expanding lazy scroll extents', ( tester, diff --git a/mobile/test/features/channels/jump_to_latest_button_test.dart b/mobile/test/features/channels/jump_to_latest_button_test.dart index 67d16e85165..af476d527ec 100644 --- a/mobile/test/features/channels/jump_to_latest_button_test.dart +++ b/mobile/test/features/channels/jump_to_latest_button_test.dart @@ -9,6 +9,38 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; void main() { + test( + 'keeps channel Latest hidden while followed-tail geometry catches up', + () { + expect( + shouldHideChannelJumpToLatest( + isAtLatest: false, + followsLatest: true, + userHasDetached: false, + ), + isTrue, + ); + expect( + shouldHideChannelJumpToLatest( + isAtLatest: false, + followsLatest: true, + userHasDetached: true, + ), + isFalse, + reason: 'A deliberate scroll away must still expose Latest.', + ); + expect( + shouldHideChannelJumpToLatest( + isAtLatest: true, + followsLatest: false, + userHasDetached: true, + ), + isTrue, + reason: 'Settled tail geometry remains authoritative.', + ); + }, + ); + testWidgets('uses native iOS liquid glass outside message-action backdrops', ( tester, ) async { @@ -107,6 +139,96 @@ void main() { } }); + testWidgets('mounts and removes native iOS Latest without animating it', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + var visible = true; + late StateSetter update; + try { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + update = setState; + return JumpToLatestSwitcher( + id: 'thread', + visible: visible, + onPressed: () {}, + ); + }, + ), + ), + ), + ); + + expect(find.byType(AnimatedSwitcher), findsNothing); + expect(find.byType(UiKitView), findsOneWidget); + + update(() => visible = false); + await tester.pump(); + + expect(find.byType(UiKitView), findsNothing); + expect( + find.byKey(const ValueKey('thread-jump-to-latest-hidden')), + findsOneWidget, + ); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('unmounts native iOS Latest while its route is inactive', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Builder( + builder: (context) => Scaffold( + body: Column( + children: [ + JumpToLatestButton(onPressed: () {}), + TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Thread')), + ), + ), + child: const Text('Open thread'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.byType(UiKitView, skipOffstage: false), findsOneWidget); + + await tester.tap(find.text('Open thread')); + await tester.pumpAndSettle(); + + expect(find.text('Thread'), findsOneWidget); + expect( + find.byType(UiKitView, skipOffstage: false), + findsNothing, + reason: 'An underlying route must not keep a native glass layer alive.', + ); + + Navigator.of(tester.element(find.text('Thread'))).pop(); + await tester.pumpAndSettle(); + + expect(find.byType(UiKitView, skipOffstage: false), findsOneWidget); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + testWidgets('gives thread controls thread-specific measurement keys', ( tester, ) async { From d1dd80c8d8ef8fea0447cf80321de01dc90ecd85 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 28 Aug 2026 11:53:34 +0100 Subject: [PATCH 2/2] Fix thread Latest recovery after composer correction Signed-off-by: kenny lopez --- .../channels/thread_detail_helpers.dart | 46 ++++++++++ .../features/channels/thread_detail_page.dart | 83 ++++++++--------- .../channels/channel_detail_page_test.dart | 92 +++++++++++++++++++ 3 files changed, 175 insertions(+), 46 deletions(-) diff --git a/mobile/lib/features/channels/thread_detail_helpers.dart b/mobile/lib/features/channels/thread_detail_helpers.dart index 4148fca4d1a..7ff39fac531 100644 --- a/mobile/lib/features/channels/thread_detail_helpers.dart +++ b/mobile/lib/features/channels/thread_detail_helpers.dart @@ -1,5 +1,51 @@ part of 'thread_detail_page.dart'; +const _threadTailScrollTolerance = 0.5; + +// Keep the direct-position correction finite in case the viewport cannot +// expose its tail (for example, continuously changing media dimensions). +const _latestTailCorrectionLimit = 8; + +Widget _trackActiveThreadScrollPosition( + Widget child, + ObjectRef activePosition, +) => Builder( + builder: (context) { + activePosition.value = Scrollable.of(context).position; + return child; + }, +); + +bool _jumpActiveThreadScrollToTail( + ObjectRef activePosition, + bool Function()? testOverride, +) { + if (testOverride != null) return testOverride(); + final position = activePosition.value; + if (position == null || !position.hasContentDimensions) return false; + // Moving the one active viewport avoids a second list and its visible bounce. + position.jumpTo(position.maxScrollExtent); + return true; +} + +Future _animateActiveThreadScrollToTail( + BuildContext context, + ObjectRef activePosition, +) async { + final position = activePosition.value; + if (position == null || !position.hasContentDimensions) return false; + if (MediaQuery.disableAnimationsOf(context)) { + position.jumpTo(position.maxScrollExtent); + return true; + } + await position.animateTo( + position.maxScrollExtent, + duration: jumpToLatestScrollDuration, + curve: jumpToLatestScrollCurve, + ); + return true; +} + /// Returns whether the thread is at its effective scroll end. /// /// Item positions can lag or briefly oscillate during lazy layout, so an exact diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index c29c6878a7d..154193b3d2f 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -60,11 +60,6 @@ const _landingHighlightDuration = Duration(seconds: 3); const _landingHighlightDelay = Duration(milliseconds: 50); const _landingHighlightTransitionDuration = Duration(milliseconds: 300); const _landingHighlightOpacity = 0.12; -const _threadTailScrollTolerance = 0.5; - -// Keep the direct-position correction finite in case the viewport cannot -// expose its tail (for example, continuously changing media dimensions). -const _latestTailCorrectionLimit = 8; /// Full-screen thread detail page. /// @@ -79,6 +74,10 @@ class ThreadDetailPage extends HookConsumerWidget { final bool isArchived; final String? initialMessageId; + /// Overrides the tail jump only in deterministic lazy-layout tests. + @visibleForTesting + final bool Function()? jumpThreadTailForTesting; + const ThreadDetailPage({ super.key, required this.threadHead, @@ -88,6 +87,7 @@ class ThreadDetailPage extends HookConsumerWidget { required this.isMember, required this.isArchived, this.initialMessageId, + this.jumpThreadTailForTesting, }); @override @@ -252,7 +252,11 @@ class ThreadDetailPage extends HookConsumerWidget { final initialTailSettle = useMemoized(InitialThreadTailSettle.new); final isAtThreadTail = useState(true); final isNavigatingToThreadTail = useState(false); - final hidesLatestForComposerTailFollow = useState(false); + // Ordinary thread entry owns a tail-follow intent before lazy item + // positions settle. Keep Latest suppressed through those initial layout + // frames; a deep-link entry must expose it for its older target instead. + final hidesLatestForInitialTailSettle = useState(initialMessageId == null); + final hidesLatestForComposerTailCorrection = useState(false); final tailCorrectionInProgress = useRef(false); final tailCorrectionGeneration = useRef(0); final activeThreadScrollPosition = useRef(null); @@ -341,44 +345,16 @@ class ThreadDetailPage extends HookConsumerWidget { ); } - Widget trackActiveScrollPosition(Widget child) { - return Builder( - builder: (itemContext) { - activeThreadScrollPosition.value = Scrollable.of( - itemContext, - ).position; - return child; - }, - ); - } + Widget trackActiveScrollPosition(Widget child) => + _trackActiveThreadScrollPosition(child, activeThreadScrollPosition); - bool jumpActiveScrollPositionToTail() { - final position = activeThreadScrollPosition.value; - if (position == null || !position.hasContentDimensions) return false; - // Move the one active viewport to its exact end. Unlike indexed - // jumpTo/scrollTo, this does not reset or cross-fade through a second - // list, so iOS never exposes the intermediate top-of-thread frame. - position.jumpTo(position.maxScrollExtent); - return true; - } + bool jumpActiveScrollPositionToTail() => _jumpActiveThreadScrollToTail( + activeThreadScrollPosition, + jumpThreadTailForTesting, + ); - Future animateActiveScrollPositionToTail() async { - final position = activeThreadScrollPosition.value; - if (position == null || !position.hasContentDimensions) return false; - if (MediaQuery.disableAnimationsOf(context)) { - position.jumpTo(position.maxScrollExtent); - return true; - } - // Match the channel's visible Latest glide while moving only the active - // thread viewport. The indexed-list animation path can create a temporary - // second list for distant targets, which caused the old top-frame bounce. - await position.animateTo( - position.maxScrollExtent, - duration: jumpToLatestScrollDuration, - curve: jumpToLatestScrollCurve, - ); - return true; - } + Future animateActiveScrollPositionToTail() => + _animateActiveThreadScrollToTail(context, activeThreadScrollPosition); void finishThreadTailCorrection({ required bool revealViewport, @@ -393,6 +369,7 @@ class ThreadDetailPage extends HookConsumerWidget { tailIntent.isDragging || userOptedOutOfTailFollow.value) { tailCorrectionInProgress.value = false; + hidesLatestForComposerTailCorrection.value = false; isNavigatingToThreadTail.value = false; isAtThreadTail.value = threadTailIsVisible(); return; @@ -413,6 +390,7 @@ class ThreadDetailPage extends HookConsumerWidget { return; } tailCorrectionInProgress.value = false; + hidesLatestForComposerTailCorrection.value = false; isNavigatingToThreadTail.value = false; if (revealViewport) initialViewportReady.value = true; isAtThreadTail.value = reachedTail; @@ -434,7 +412,7 @@ class ThreadDetailPage extends HookConsumerWidget { void followThreadTailFromComposer() { if (userDragDetachedTailFollow.value) return; - hidesLatestForComposerTailFollow.value = true; + hidesLatestForComposerTailCorrection.value = true; initialTailSettle.abandon(); initialViewportReady.value = true; tailIntent.endDrag(); @@ -443,9 +421,20 @@ class ThreadDetailPage extends HookConsumerWidget { followsThreadTail.value = true; final reachedTail = threadTailIsVisible(); isAtThreadTail.value = reachedTail; - if (!reachedTail) correctThreadTailInstantly(); + if (reachedTail) { + hidesLatestForComposerTailCorrection.value = false; + } else { + correctThreadTailInstantly(); + } } + useEffect(() { + if (!composerHasFocus) { + hidesLatestForComposerTailCorrection.value = false; + } + return null; + }, [composerHasFocus]); + useEffect( () { void onPositionsChanged() { @@ -866,7 +855,8 @@ class ThreadDetailPage extends HookConsumerWidget { child: _ThreadMessageList( viewport: listViewport, onUserScrollStart: () { - hidesLatestForComposerTailFollow.value = false; + hidesLatestForInitialTailSettle.value = false; + hidesLatestForComposerTailCorrection.value = false; initialTailSettle.abandon(); initialViewportReady.value = true; tailCorrectionInProgress.value = false; @@ -985,7 +975,8 @@ class ThreadDetailPage extends HookConsumerWidget { threadViewportVisible && hasFetchedReplies && !isNavigatingToThreadTail.value && - !hidesLatestForComposerTailFollow.value && + !hidesLatestForInitialTailSettle.value && + !hidesLatestForComposerTailCorrection.value && !(composerHasFocus && !userDragDetachedTailFollow.value) && !isAtThreadTail.value, onPressed: scrollToThreadLatest, diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 1533a135fb3..85ab76a14d7 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -12943,6 +12943,81 @@ void main() { ); } + testWidgets( + 'thread shows Latest after composer tail correction exhausts and focus leaves', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + final rootEvent = _textMsg( + id: 'thread-root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final replies = [ + for (var i = 0; i < 30; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'bob', + content: 'Reply $i', + createdAt: 1100 + i, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + threadReplies: {'thread-root': replies}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + home: ThreadDetailPage( + threadHead: formatTimeline([rootEvent]).single, + allMessages: formatTimeline([rootEvent, replies[5]]), + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + initialMessageId: 'reply-5', + jumpThreadTailForTesting: () => true, + ), + ), + ); + await tester.pumpAndSettle(); + + const latestButton = ValueKey('thread-jump-to-latest'); + expect(find.byKey(latestButton), findsOneWidget); + + await tester.tap(find.text('Reply in thread…').hitTestable()); + await tester.pump(); + for (var frame = 0; frame < 10; frame++) { + await tester.pump(); + } + + final focusNode = tester + .widget(find.byType(TextField)) + .focusNode!; + expect(focusNode.hasFocus, isTrue); + expect( + find.byKey(const ValueKey('thread-message-group-reply-29')), + findsNothing, + reason: 'The lazy tail must remain unlaid after bounded correction.', + ); + + focusNode.unfocus(); + await tester.pumpAndSettle(); + + expect(focusNode.hasFocus, isFalse); + expect(find.byKey(latestButton), findsOneWidget); + }, + ); + testWidgets('thread hides initial tail placement until it is settled', ( tester, ) async { @@ -13306,6 +13381,21 @@ void main() { findsNothing, ); + final landingScrollable = tester.state( + find.descendant(of: list, matching: find.byType(Scrollable)).first, + ); + landingScrollable.position.jumpTo( + landingScrollable.position.maxScrollExtent - 24, + ); + await tester.pump(); + expect( + find.byKey(const ValueKey('thread-jump-to-latest')), + findsNothing, + reason: + 'A stale landing measurement must not expose Latest before the ' + 'user explicitly browses history.', + ); + await tester.drag(list, const Offset(0, 500)); await tester.pumpAndSettle(); @@ -13470,6 +13560,8 @@ void main() { ); positionedList.itemScrollController!.jumpTo(index: 5); await tester.pumpAndSettle(); + await tester.drag(list, const Offset(0, 20)); + await tester.pumpAndSettle(); expect( find.byKey(const ValueKey('thread-message-group-reply-159')), findsNothing,