From 9f986988fbe1a61b0a6806d56f3a7c7baffbb6b6 Mon Sep 17 00:00:00 2001 From: npub1em3jmyn4vu57urqf03txrwreccvejvwdy5c4er8nnrwt7rc4tncscs3ssu Date: Tue, 4 Aug 2026 06:24:30 -0400 Subject: [PATCH 1/7] fix(mobile): settle hydrated threads on latest reply Signed-off-by: npub1em3jmyn4vu57urqf03txrwreccvejvwdy5c4er8nnrwt7rc4tncscs3ssu Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../features/channels/thread_detail_page.dart | 45 ++++++++++++++++--- .../channels/channel_detail_page_test.dart | 30 +++++++++++-- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index fff7f5834b..fbb778a2fd 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -177,15 +177,48 @@ class ThreadDetailPage extends HookConsumerWidget { // while the last item is on screen, scroll it into view. If the user has // scrolled up to read, leave them where they are. final hasFetchedReplies = fetchedReplies != null; - final didEstablishInitialReplies = useRef(hasFetchedReplies); + final didEstablishInitialReplies = useRef(false); + final initialSettleGeneration = useRef(0); final previousReplyCount = useRef(replies.length); useEffect(() { // The first authoritative query result is hydration, not a live arrival. - // Establish the baseline without moving the user away from the head. + // Once every relay page has resolved, settle an ordinary thread open on + // its newest reply. Deep links retain ownership of their explicit target. if (!hasFetchedReplies) return null; if (!didEstablishInitialReplies.value) { - didEstablishInitialReplies.value = true; previousReplyCount.value = replies.length; + if (initialMessageId == null && replies.isNotEmpty) { + final generation = ++initialSettleGeneration.value; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted || + generation != initialSettleGeneration.value) { + return; + } + // Let live events received during hydration rebuild the list before + // committing the initial target. Their effect invalidates this + // generation and schedules the current tail instead. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted || + !itemScrollController.isAttached || + generation != initialSettleGeneration.value) { + return; + } + itemScrollController + .scrollTo( + index: indexForReply(replies.length - 1), + alignment: 0.8, + duration: const Duration(milliseconds: 1), + ) + .whenComplete(() { + if (generation == initialSettleGeneration.value) { + didEstablishInitialReplies.value = true; + } + }); + }); + }); + } else { + didEstablishInitialReplies.value = true; + } return null; } @@ -200,9 +233,9 @@ class ThreadDetailPage extends HookConsumerWidget { final previousLastIndex = previous == 0 ? headIndex : indexForReply(previous - 1); - final wasAtTail = - positions.isEmpty || - positions.any((position) => position.index >= previousLastIndex); + final wasAtTail = positions.any( + (position) => position.index == previousLastIndex, + ); final localPubkey = currentPubkey?.toLowerCase(); final hasNewLocalReply = localPubkey != null && diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 912233aba0..a11160c880 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -3448,7 +3448,7 @@ void main() { }); testWidgets( - 'initial thread hydration keeps the head visible instead of following the tail', + 'initial thread hydration settles on the latest reply after pagination', (tester) async { final rootEvent = _textMsg( id: 'thread-root', @@ -3469,10 +3469,12 @@ void main() { ), ]; final completer = Completer>(); + final messagesNotifier = _FakeMessagesNotifier([rootEvent]); await tester.pumpWidget( _buildTestable( messages: [rootEvent], + messagesNotifier: messagesNotifier, pendingThreadReplies: {'thread-root': completer.future}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), @@ -3502,16 +3504,36 @@ void main() { ); completer.complete(replies); + await tester.pump(); + final latestLiveReply = _textMsg( + id: 'reply-live', + pubkey: 'bob', + content: 'Live during settle', + createdAt: 1200, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ); + messagesNotifier.setMessages([rootEvent, latestLiveReply]); await tester.pumpAndSettle(); expect( find.byKey(const ValueKey('thread-message-group-thread-root')), - findsOneWidget, + findsNothing, ); expect( - find.byKey(const ValueKey('thread-message-group-reply-29')), - findsNothing, + find.byKey(const ValueKey('thread-message-group-reply-live')), + findsOneWidget, ); + final listBottom = tester + .getBottomLeft(find.byKey(const ValueKey('thread-message-list'))) + .dy; + final latestBottom = tester + .getBottomLeft( + find.byKey(const ValueKey('thread-message-group-reply-live')), + ) + .dy; + expect(latestBottom, closeTo(listBottom - Grid.xs, 1)); }, ); From 707caa90757fa37e8e22240cd8c94bda909fb99e Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 06:58:03 -0400 Subject: [PATCH 2/7] refactor(mobile): extract initial thread settle Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../channels/initial_thread_tail_settle.dart | 50 +++++++++++++++++++ .../features/channels/thread_detail_page.dart | 45 ++++------------- 2 files changed, 60 insertions(+), 35 deletions(-) create mode 100644 mobile/lib/features/channels/initial_thread_tail_settle.dart diff --git a/mobile/lib/features/channels/initial_thread_tail_settle.dart b/mobile/lib/features/channels/initial_thread_tail_settle.dart new file mode 100644 index 0000000000..e5d04968db --- /dev/null +++ b/mobile/lib/features/channels/initial_thread_tail_settle.dart @@ -0,0 +1,50 @@ +import 'package:flutter/widgets.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; + +/// Settles an ordinary thread open on the latest hydrated reply after layout. +/// +/// Scheduling again before completion invalidates callbacks aimed at an older +/// tail, allowing a rebuild with newly arrived replies to choose the target. +class InitialThreadTailSettle { + var _generation = 0; + var _isComplete = false; + + bool get isComplete => _isComplete; + + void schedule({ + required BuildContext context, + required ItemScrollController controller, + required int? targetIndex, + }) { + if (_isComplete) return; + + final generation = ++_generation; + if (targetIndex == null) { + _isComplete = true; + return; + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted || generation != _generation) return; + + // Let events received during hydration rebuild the list before committing + // the target. That rebuild schedules a new generation at the current tail. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted || + !controller.isAttached || + generation != _generation) { + return; + } + controller + .scrollTo( + index: targetIndex, + alignment: 0.8, + duration: const Duration(milliseconds: 1), + ) + .whenComplete(() { + if (generation == _generation) _isComplete = true; + }); + }); + }); + } +} diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index fbb778a2fd..893a06f2d4 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -24,6 +24,7 @@ import 'composer_dock_size_reporter.dart'; import 'date_formatters.dart'; import 'day_divider.dart'; import '../profile/user_profile_sheet.dart'; +import 'initial_thread_tail_settle.dart'; import 'message_actions.dart'; import 'message_content.dart'; import 'reaction_row.dart'; @@ -177,48 +178,22 @@ class ThreadDetailPage extends HookConsumerWidget { // while the last item is on screen, scroll it into view. If the user has // scrolled up to read, leave them where they are. final hasFetchedReplies = fetchedReplies != null; - final didEstablishInitialReplies = useRef(false); - final initialSettleGeneration = useRef(0); + final initialTailSettle = useMemoized(InitialThreadTailSettle.new); final previousReplyCount = useRef(replies.length); useEffect(() { // The first authoritative query result is hydration, not a live arrival. // Once every relay page has resolved, settle an ordinary thread open on // its newest reply. Deep links retain ownership of their explicit target. if (!hasFetchedReplies) return null; - if (!didEstablishInitialReplies.value) { + if (!initialTailSettle.isComplete) { previousReplyCount.value = replies.length; - if (initialMessageId == null && replies.isNotEmpty) { - final generation = ++initialSettleGeneration.value; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!context.mounted || - generation != initialSettleGeneration.value) { - return; - } - // Let live events received during hydration rebuild the list before - // committing the initial target. Their effect invalidates this - // generation and schedules the current tail instead. - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!context.mounted || - !itemScrollController.isAttached || - generation != initialSettleGeneration.value) { - return; - } - itemScrollController - .scrollTo( - index: indexForReply(replies.length - 1), - alignment: 0.8, - duration: const Duration(milliseconds: 1), - ) - .whenComplete(() { - if (generation == initialSettleGeneration.value) { - didEstablishInitialReplies.value = true; - } - }); - }); - }); - } else { - didEstablishInitialReplies.value = true; - } + initialTailSettle.schedule( + context: context, + controller: itemScrollController, + targetIndex: initialMessageId == null && replies.isNotEmpty + ? indexForReply(replies.length - 1) + : null, + ); return null; } From f93c2eff5bf03903b7eab33f26ab8a4cbe2b1369 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 07:15:21 -0400 Subject: [PATCH 3/7] test(mobile): avoid exact thread tail geometry Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../test/features/channels/channel_detail_page_test.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index a11160c880..789c689665 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -3525,15 +3525,15 @@ void main() { find.byKey(const ValueKey('thread-message-group-reply-live')), findsOneWidget, ); - final listBottom = tester - .getBottomLeft(find.byKey(const ValueKey('thread-message-list'))) - .dy; + final listRect = tester.getRect( + find.byKey(const ValueKey('thread-message-list')), + ); final latestBottom = tester .getBottomLeft( find.byKey(const ValueKey('thread-message-group-reply-live')), ) .dy; - expect(latestBottom, closeTo(listBottom - Grid.xs, 1)); + expect(latestBottom, greaterThan(listRect.center.dy)); }, ); From c718611641d362bffb208f68db5c967341f5aacb Mon Sep 17 00:00:00 2001 From: loganj Date: Tue, 4 Aug 2026 14:39:11 +0000 Subject: [PATCH 4/7] fix(mobile): preserve short thread anchoring Signed-off-by: loganj Co-authored-by: Codex --- .../channels/initial_thread_tail_settle.dart | 14 ++++ .../features/channels/thread_detail_page.dart | 1 + .../channels/channel_detail_page_test.dart | 74 +++++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/mobile/lib/features/channels/initial_thread_tail_settle.dart b/mobile/lib/features/channels/initial_thread_tail_settle.dart index e5d04968db..c6e119c972 100644 --- a/mobile/lib/features/channels/initial_thread_tail_settle.dart +++ b/mobile/lib/features/channels/initial_thread_tail_settle.dart @@ -14,6 +14,7 @@ class InitialThreadTailSettle { void schedule({ required BuildContext context, required ItemScrollController controller, + required ItemPositionsListener positionsListener, required int? targetIndex, }) { if (_isComplete) return; @@ -35,6 +36,19 @@ class InitialThreadTailSettle { generation != _generation) { return; } + final targetIsFullyVisible = positionsListener.itemPositions.value.any( + (position) => + position.index == targetIndex && + position.itemLeadingEdge >= 0 && + position.itemTrailingEdge <= 1, + ); + // Short threads already expose their tail from the top anchor. Moving + // that fully visible target down would only add empty space above the + // head. A clipped tail still takes the measured correction path. + if (targetIsFullyVisible) { + _isComplete = true; + return; + } controller .scrollTo( index: targetIndex, diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 893a06f2d4..0b25f00626 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -190,6 +190,7 @@ class ThreadDetailPage extends HookConsumerWidget { initialTailSettle.schedule( context: context, controller: itemScrollController, + positionsListener: itemPositionsListener, targetIndex: initialMessageId == null && replies.isNotEmpty ? indexForReply(replies.length - 1) : null, diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 789c689665..c19f4a9573 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -3447,6 +3447,80 @@ 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>(); + + 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, + ), + ), + ); + await tester.pumpAndSettle(); + + final headFinder = find.byKey( + const ValueKey('thread-message-group-thread-root'), + ); + final initialHeadY = tester.getTopLeft(headFinder).dy; + + completer.complete(replies); + 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)); + }); + testWidgets( 'initial thread hydration settles on the latest reply after pagination', (tester) async { From 03d2fb27fbe5a3e802322e825571eb314f901b41 Mon Sep 17 00:00:00 2001 From: npub1em3jmyn4vu57urqf03txrwreccvejvwdy5c4er8nnrwt7rc4tncscs3ssu Date: Tue, 4 Aug 2026 15:27:55 -0400 Subject: [PATCH 5/7] fix(mobile): preserve thread tail scroll intent Treat the overlaid composer as hidden viewport space during the initial settle, and preserve an explicit user-scroll opt-out from later tail realignment. Signed-off-by: npub1em3jmyn4vu57urqf03txrwreccvejvwdy5c4er8nnrwt7rc4tncscs3ssu --- .../channels/initial_thread_tail_settle.dart | 5 +++-- .../features/channels/thread_detail_page.dart | 16 +++++++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/mobile/lib/features/channels/initial_thread_tail_settle.dart b/mobile/lib/features/channels/initial_thread_tail_settle.dart index c6e119c972..aaac003b66 100644 --- a/mobile/lib/features/channels/initial_thread_tail_settle.dart +++ b/mobile/lib/features/channels/initial_thread_tail_settle.dart @@ -16,6 +16,7 @@ class InitialThreadTailSettle { required ItemScrollController controller, required ItemPositionsListener positionsListener, required int? targetIndex, + required double hiddenBottomFraction, }) { if (_isComplete) return; @@ -40,7 +41,7 @@ class InitialThreadTailSettle { (position) => position.index == targetIndex && position.itemLeadingEdge >= 0 && - position.itemTrailingEdge <= 1, + position.itemTrailingEdge <= 1 - hiddenBottomFraction, ); // Short threads already expose their tail from the top anchor. Moving // that fully visible target down would only add empty space above the @@ -52,7 +53,7 @@ class InitialThreadTailSettle { controller .scrollTo( index: targetIndex, - alignment: 0.8, + alignment: 0.0, duration: const Duration(milliseconds: 1), ) .whenComplete(() { diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 0b25f00626..b21c3535e5 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -118,6 +118,7 @@ class ThreadDetailPage extends HookConsumerWidget { final itemPositionsListener = useMemoized(ItemPositionsListener.create); final didJumpToInitialMessage = useRef(false); final followsThreadTail = useRef(false); + final userOptedOutOfTailFollow = useRef(false); final pendingTailAlignment = useRef(null); final tailRealignmentQueued = useRef(false); @@ -137,7 +138,9 @@ class ThreadDetailPage extends HookConsumerWidget { useEffect(() { void onPositionsChanged() { - if (threadTailIsVisible()) followsThreadTail.value = true; + if (!userOptedOutOfTailFollow.value && threadTailIsVisible()) { + followsThreadTail.value = true; + } } itemPositionsListener.itemPositions.addListener(onPositionsChanged); @@ -194,6 +197,8 @@ class ThreadDetailPage extends HookConsumerWidget { targetIndex: initialMessageId == null && replies.isNotEmpty ? indexForReply(replies.length - 1) : null, + hiddenBottomFraction: + composerDockHeight.value / MediaQuery.sizeOf(context).height, ); return null; } @@ -273,7 +278,9 @@ class ThreadDetailPage extends HookConsumerWidget { final heightDelta = height - previousHeight; if (heightDelta.abs() < 0.5) return; - final shouldFollowTail = followsThreadTail.value || threadTailIsVisible(); + final shouldFollowTail = + !userOptedOutOfTailFollow.value && + (followsThreadTail.value || threadTailIsVisible()); if (shouldFollowTail) followsThreadTail.value = true; composerDockHeight.value = height; if (heightDelta <= 0 || !shouldFollowTail) { @@ -306,7 +313,9 @@ class ThreadDetailPage extends HookConsumerWidget { // keyboard appears. Re-align after that latter layout pass too, but only // while the user was already following the thread tail. void realignThreadTailAfterMetricsChange() { - final shouldFollowTail = followsThreadTail.value || threadTailIsVisible(); + final shouldFollowTail = + !userOptedOutOfTailFollow.value && + (followsThreadTail.value || threadTailIsVisible()); if (!shouldFollowTail || tailRealignmentQueued.value) return; followsThreadTail.value = true; tailRealignmentQueued.value = true; @@ -358,6 +367,7 @@ class ThreadDetailPage extends HookConsumerWidget { Expanded( child: KeyboardDismissOnDrag( onUserScrollStart: () { + userOptedOutOfTailFollow.value = true; followsThreadTail.value = false; pendingTailAlignment.value = null; }, From 25cc503e15b9f00c0d7453b22f47cb6204d6ab0e Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 15:31:20 -0400 Subject: [PATCH 6/7] test(mobile): cover dock-aware thread settling Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../channels/channel_detail_page_test.dart | 110 +++++++++++++++++- 1 file changed, 104 insertions(+), 6 deletions(-) diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index c19f4a9573..31a5c0f021 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -3582,7 +3582,7 @@ void main() { final latestLiveReply = _textMsg( id: 'reply-live', pubkey: 'bob', - content: 'Live during settle', + content: List.filled(10, 'Tall live reply').join('\n'), createdAt: 1200, extraTags: const [ ['e', 'thread-root', '', 'reply'], @@ -3602,12 +3602,110 @@ void main() { final listRect = tester.getRect( find.byKey(const ValueKey('thread-message-list')), ); - final latestBottom = tester - .getBottomLeft( - find.byKey(const ValueKey('thread-message-group-reply-live')), - ) + final latestReply = find.byKey( + const ValueKey('thread-message-group-reply-live'), + ); + final latestRect = tester.getRect(latestReply); + final composerTop = tester + .getTopLeft(find.byKey(const ValueKey('composer-surface'))) .dy; - expect(latestBottom, greaterThan(listRect.center.dy)); + expect(latestRect.bottom, lessThanOrEqualTo(composerTop)); + expect(latestRect.bottom, greaterThan(listRect.center.dy)); + }, + ); + + testWidgets( + 'dragging away from the settled tail opts out of keyboard realignment', + (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'), + }, + ), + ); + 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(); + + final list = find.byKey(const ValueKey('thread-message-list')); + for (var i = 0; i < 4; i++) { + await tester.drag(list, const Offset(0, 100)); + await tester.pumpAndSettle(); + } + expect( + find.byKey(const ValueKey('thread-message-group-reply-29')), + findsNothing, + ); + final visibleBeforeResize = tester + .widgetList( + find.byWidgetPredicate( + (widget) => + widget.key is ValueKey && + (widget.key! as ValueKey).value.startsWith( + 'thread-message-group-', + ), + ), + ) + .map((widget) => widget.key) + .toSet(); + + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('thread-message-group-reply-29')), + findsNothing, + ); + expect( + tester + .widgetList( + find.byWidgetPredicate( + (widget) => visibleBeforeResize.contains(widget.key), + ), + ) + .map((widget) => widget.key), + isNotEmpty, + ); }, ); From 3c3696b811a8b3588dd66439824ff140c62098ba Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 15:46:04 -0400 Subject: [PATCH 7/7] chore(mobile): satisfy thread page size ratchet Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- mobile/lib/features/channels/thread_detail_page.dart | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index b21c3535e5..ec4dc4897a 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -121,8 +121,6 @@ class ThreadDetailPage extends HookConsumerWidget { final userOptedOutOfTailFollow = useRef(false); final pendingTailAlignment = useRef(null); final tailRealignmentQueued = useRef(false); - - // Item 0 is the thread head; reply `i` lives at `i + 1`. const headIndex = 0; int indexForReply(int chronologicalIndex) => chronologicalIndex + 1; @@ -165,9 +163,6 @@ class ThreadDetailPage extends HookConsumerWidget { if (targetIndex == null || didJumpToInitialMessage.value) return null; WidgetsBinding.instance.addPostFrameCallback((_) { if (!context.mounted || !itemScrollController.isAttached) return; - // The provisional route snapshot can make the linked reply look like - // the tail. This authoritative deep-link jump intentionally leaves - // the user at an older item, so it must opt out of follow-tail first. followsThreadTail.value = false; pendingTailAlignment.value = null; itemScrollController.jumpTo(index: targetIndex, alignment: 0.35); @@ -184,9 +179,6 @@ class ThreadDetailPage extends HookConsumerWidget { final initialTailSettle = useMemoized(InitialThreadTailSettle.new); final previousReplyCount = useRef(replies.length); useEffect(() { - // The first authoritative query result is hydration, not a live arrival. - // Once every relay page has resolved, settle an ordinary thread open on - // its newest reply. Deep links retain ownership of their explicit target. if (!hasFetchedReplies) return null; if (!initialTailSettle.isComplete) { previousReplyCount.value = replies.length; @@ -208,9 +200,6 @@ class ThreadDetailPage extends HookConsumerWidget { if (replies.length <= previous) return null; final positions = itemPositionsListener.itemPositions.value; final lastIndex = indexForReply(replies.length - 1); - // Positions still describe the list as it was *before* these replies, so - // compare against the old tail. Measuring against the new one only reads - // as "at the tail" when exactly one reply arrived. final previousLastIndex = previous == 0 ? headIndex : indexForReply(previous - 1);