From eeea5f56b69afcafd42b70309c421258afcc786c Mon Sep 17 00:00:00 2001 From: npub1em3jmyn4vu57urqf03txrwreccvejvwdy5c4er8nnrwt7rc4tncscs3ssu Date: Tue, 4 Aug 2026 06:24:30 -0400 Subject: [PATCH 01/20] 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 2ce862ea711..0a2f06436bd 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -178,15 +178,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; } @@ -201,9 +234,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 15cf79a3133..6a0eca12280 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -4086,7 +4086,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', @@ -4107,10 +4107,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'), @@ -4140,16 +4142,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 7365fd169c2bd2549fead5dc2b473e1e84a0d409 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 02/20] 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 00000000000..e5d04968dbb --- /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 0a2f06436bd..6d56cc7b50f 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_long_press_region.dart'; import 'message_content.dart'; @@ -178,48 +179,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 a87f28c3d3cced2ec5b94e5aae7c5fa855bf333f 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 03/20] 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 6a0eca12280..7ad01bd08dc 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -4163,15 +4163,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 3564958eebaa28abc89bc7755edf05516cf859f9 Mon Sep 17 00:00:00 2001 From: loganj Date: Tue, 4 Aug 2026 14:39:11 +0000 Subject: [PATCH 04/20] 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 e5d04968dbb..c6e119c9720 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 6d56cc7b50f..aa6720bca3a 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -191,6 +191,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 7ad01bd08dc..38e3c675ae5 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -4085,6 +4085,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 b7030a4e85b38753ea188162b8b767938db0b782 Mon Sep 17 00:00:00 2001 From: npub1em3jmyn4vu57urqf03txrwreccvejvwdy5c4er8nnrwt7rc4tncscs3ssu Date: Tue, 4 Aug 2026 15:27:55 -0400 Subject: [PATCH 05/20] 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 c6e119c9720..aaac003b664 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 aa6720bca3a..d57e8d6bd92 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -119,6 +119,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); @@ -138,7 +139,9 @@ class ThreadDetailPage extends HookConsumerWidget { useEffect(() { void onPositionsChanged() { - if (threadTailIsVisible()) followsThreadTail.value = true; + if (!userOptedOutOfTailFollow.value && threadTailIsVisible()) { + followsThreadTail.value = true; + } } itemPositionsListener.itemPositions.addListener(onPositionsChanged); @@ -195,6 +198,8 @@ class ThreadDetailPage extends HookConsumerWidget { targetIndex: initialMessageId == null && replies.isNotEmpty ? indexForReply(replies.length - 1) : null, + hiddenBottomFraction: + composerDockHeight.value / MediaQuery.sizeOf(context).height, ); return null; } @@ -274,7 +279,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) { @@ -307,7 +314,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; @@ -359,6 +368,7 @@ class ThreadDetailPage extends HookConsumerWidget { Expanded( child: KeyboardDismissOnDrag( onUserScrollStart: () { + userOptedOutOfTailFollow.value = true; followsThreadTail.value = false; pendingTailAlignment.value = null; }, From 9f900abb60dbf9c8c4ff45eaf8c4aa11de6806ae 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 06/20] 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 38e3c675ae5..11e542e1647 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -4220,7 +4220,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'], @@ -4240,12 +4240,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 c66e4001f2bd9a153db4111ac8dba45fd7c91d85 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 07/20] 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 d57e8d6bd92..cf869001bd6 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -122,8 +122,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; @@ -166,9 +164,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); @@ -185,9 +180,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; @@ -209,9 +201,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); From eea4c9cf459d660d13d641713f4a0526465725c1 Mon Sep 17 00:00:00 2001 From: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Date: Mon, 10 Aug 2026 12:38:10 -0400 Subject: [PATCH 08/20] fix(mobile): resume thread tail follow at tail Signed-off-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> --- .../channels/initial_thread_tail_settle.dart | 9 ++++++ .../features/channels/thread_detail_page.dart | 5 ++++ .../widgets/keyboard_dismiss_on_drag.dart | 10 ++++++- .../channels/channel_detail_page_test.dart | 28 ++++++++++++++++++- .../keyboard_dismiss_on_drag_test.dart | 19 +++++++++++++ 5 files changed, 69 insertions(+), 2 deletions(-) diff --git a/mobile/lib/features/channels/initial_thread_tail_settle.dart b/mobile/lib/features/channels/initial_thread_tail_settle.dart index aaac003b664..d5a1e264ffc 100644 --- a/mobile/lib/features/channels/initial_thread_tail_settle.dart +++ b/mobile/lib/features/channels/initial_thread_tail_settle.dart @@ -9,8 +9,17 @@ class InitialThreadTailSettle { var _generation = 0; var _isComplete = false; + /// Whether no more settling is needed. + /// + /// Completion occurs when there is no tail target, the target is already + /// visible after hydration has settled, or the scheduled scroll finishes. bool get isComplete => _isComplete; + /// Schedules a settle after each hydrated thread layout until [isComplete]. + /// + /// A later schedule replaces an earlier target while replies are still + /// arriving. The final target is left in place when already visible; otherwise + /// it scrolls above the portion obscured by the composer dock. void schedule({ required BuildContext context, required ItemScrollController controller, diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index cf869001bd6..c76f8ddfb0b 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -361,6 +361,11 @@ class ThreadDetailPage extends HookConsumerWidget { followsThreadTail.value = false; pendingTailAlignment.value = null; }, + onUserScrollEnd: () { + if (!threadTailIsVisible()) return; + userOptedOutOfTailFollow.value = false; + followsThreadTail.value = true; + }, child: ScrollablePositionedList.builder( key: const ValueKey('thread-message-list'), itemScrollController: itemScrollController, diff --git a/mobile/lib/shared/widgets/keyboard_dismiss_on_drag.dart b/mobile/lib/shared/widgets/keyboard_dismiss_on_drag.dart index 93371bdd0bd..6356f76dcf9 100644 --- a/mobile/lib/shared/widgets/keyboard_dismiss_on_drag.dart +++ b/mobile/lib/shared/widgets/keyboard_dismiss_on_drag.dart @@ -29,9 +29,13 @@ class KeyboardDismissOnDrag extends HookWidget { final Widget child; final VoidCallback? onUserScrollStart; + /// Called after a drag-originated scroll ends, not after programmatic scrolls. + final VoidCallback? onUserScrollEnd; + const KeyboardDismissOnDrag({ super.key, this.onUserScrollStart, + this.onUserScrollEnd, required this.child, }); @@ -40,14 +44,18 @@ class KeyboardDismissOnDrag extends HookWidget { // A ref, not state: accumulating travel must never trigger a rebuild of // the message list this wraps. final downwardTravel = useRef(0.0); + final userScrollInProgress = useRef(false); bool handle(ScrollNotification notification) { if (notification is ScrollStartNotification) { - if (notification.dragDetails != null) onUserScrollStart?.call(); + userScrollInProgress.value = notification.dragDetails != null; + if (userScrollInProgress.value) onUserScrollStart?.call(); downwardTravel.value = 0; return false; } if (notification is ScrollEndNotification) { + if (userScrollInProgress.value) onUserScrollEnd?.call(); + userScrollInProgress.value = false; downwardTravel.value = 0; return false; } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 11e542e1647..79e152c3854 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -4253,7 +4253,7 @@ void main() { ); testWidgets( - 'dragging away from the settled tail opts out of keyboard realignment', + 'dragging away opts out, then returning to the tail resumes keyboard realignment', (tester) async { tester.view.physicalSize = const Size(400, 800); tester.view.devicePixelRatio = 1; @@ -4344,6 +4344,32 @@ void main() { .map((widget) => widget.key), isNotEmpty, ); + + // Return the viewport to its original geometry while opt-out remains + // active. This must not itself pull the list back to the tail. + tester.view.viewInsets = FakeViewPadding.zero; + await tester.pumpAndSettle(); + + // Returning to the tail is a deliberate choice to resume following. + // Only the scroll-end callback may clear the opt-out state, so this + // reverse drag must complete before geometry changes re-align the tail. + for (var i = 0; i < 12; i++) { + await tester.drag(list, const Offset(0, -100)); + await tester.pumpAndSettle(); + } + final latestReply = find.byKey( + const ValueKey('thread-message-group-reply-29'), + ); + expect(latestReply, findsOneWidget); + + final composerSurface = find.byKey(const ValueKey('composer-surface')); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pumpAndSettle(); + + expect( + tester.getBottomLeft(latestReply).dy, + lessThanOrEqualTo(tester.getTopLeft(composerSurface).dy), + ); }, ); diff --git a/mobile/test/shared/widgets/keyboard_dismiss_on_drag_test.dart b/mobile/test/shared/widgets/keyboard_dismiss_on_drag_test.dart index 19424b7c82e..e228c5226e3 100644 --- a/mobile/test/shared/widgets/keyboard_dismiss_on_drag_test.dart +++ b/mobile/test/shared/widgets/keyboard_dismiss_on_drag_test.dart @@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart'; Widget _testable({ required FocusNode focusNode, VoidCallback? onUserScrollStart, + VoidCallback? onUserScrollEnd, }) { return MaterialApp( home: Scaffold( @@ -16,6 +17,7 @@ Widget _testable({ Expanded( child: KeyboardDismissOnDrag( onUserScrollStart: onUserScrollStart, + onUserScrollEnd: onUserScrollEnd, child: ListView( children: [ for (var i = 0; i < 40; i++) @@ -125,6 +127,23 @@ void main() { expect(userScrollStarts, 1); }); + testWidgets('reports a user scroll ending', (tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + var userScrollEnds = 0; + await tester.pumpWidget( + _testable( + focusNode: focusNode, + onUserScrollEnd: () => userScrollEnds += 1, + ), + ); + + await tester.drag(find.text('row 3'), const Offset(0, -100)); + await tester.pumpAndSettle(); + + expect(userScrollEnds, 1); + }); + testWidgets('an upward drag never dismisses, however far it goes', ( tester, ) async { From 128b8cb2e1789d6e7fccefa0bd8a8f4e305aefea Mon Sep 17 00:00:00 2001 From: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Mon, 10 Aug 2026 13:33:37 -0400 Subject: [PATCH 09/20] chore(mobile): satisfy rebased thread page ratchet Signed-off-by: Larry <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 c76f8ddfb0b..70a4eb7b4d7 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -172,10 +172,6 @@ class ThreadDetailPage extends HookConsumerWidget { return null; }, [initialMessageId, fetchedReplies, replies.length]); - // A top-anchored list doesn't stick to the newest item the way the old - // reversed one did, so follow the tail explicitly: when a reply arrives - // 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 initialTailSettle = useMemoized(InitialThreadTailSettle.new); final previousReplyCount = useRef(replies.length); @@ -213,9 +209,6 @@ class ThreadDetailPage extends HookConsumerWidget { replies .skip(previous) .any((reply) => reply.pubkey.toLowerCase() == localPubkey); - // A reply the current user just sent must be visible even if they were - // reading at the head of a long thread. Remote arrivals still respect - // the user's scroll position. if (!wasAtTail && !hasNewLocalReply) return null; WidgetsBinding.instance.addPostFrameCallback((_) { if (!context.mounted || !itemScrollController.isAttached) return; @@ -370,10 +363,6 @@ class ThreadDetailPage extends HookConsumerWidget { key: const ValueKey('thread-message-list'), itemScrollController: itemScrollController, itemPositionsListener: itemPositionsListener, - // Top-anchored, head first, replies flowing down — matching - // desktop's thread panel. The old reversed list bottom-anchored - // the content, which jammed the head against the composer - // whenever a thread had only a handful of replies. padding: EdgeInsets.only( left: Grid.gutter, right: Grid.gutter, From 22475d85b9fde15e1756a3eeb2752c898953aed6 Mon Sep 17 00:00:00 2001 From: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Mon, 10 Aug 2026 14:15:39 -0400 Subject: [PATCH 10/20] fix(mobile): settle thread tail between overlays Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../channels/initial_thread_tail_settle.dart | 7 +- .../features/channels/thread_detail_page.dart | 10 +-- .../channels/channel_detail_page_test.dart | 71 +++++++++++++++++++ 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/mobile/lib/features/channels/initial_thread_tail_settle.dart b/mobile/lib/features/channels/initial_thread_tail_settle.dart index d5a1e264ffc..c8af8098834 100644 --- a/mobile/lib/features/channels/initial_thread_tail_settle.dart +++ b/mobile/lib/features/channels/initial_thread_tail_settle.dart @@ -19,12 +19,13 @@ class InitialThreadTailSettle { /// /// A later schedule replaces an earlier target while replies are still /// arriving. The final target is left in place when already visible; otherwise - /// it scrolls above the portion obscured by the composer dock. + /// it scrolls into the viewport between the measured top and bottom overlays. void schedule({ required BuildContext context, required ItemScrollController controller, required ItemPositionsListener positionsListener, required int? targetIndex, + required double hiddenTopFraction, required double hiddenBottomFraction, }) { if (_isComplete) return; @@ -49,7 +50,7 @@ class InitialThreadTailSettle { final targetIsFullyVisible = positionsListener.itemPositions.value.any( (position) => position.index == targetIndex && - position.itemLeadingEdge >= 0 && + position.itemLeadingEdge >= hiddenTopFraction && position.itemTrailingEdge <= 1 - hiddenBottomFraction, ); // Short threads already expose their tail from the top anchor. Moving @@ -62,7 +63,7 @@ class InitialThreadTailSettle { controller .scrollTo( index: targetIndex, - alignment: 0.0, + alignment: hiddenTopFraction, 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 70a4eb7b4d7..9c38c5c3f01 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -175,8 +175,11 @@ class ThreadDetailPage extends HookConsumerWidget { final hasFetchedReplies = fetchedReplies != null; final initialTailSettle = useMemoized(InitialThreadTailSettle.new); final previousReplyCount = useRef(replies.length); + final viewportHeight = MediaQuery.sizeOf(context).height; + final topOverlayFraction = frostedAppBarHeight(context) / viewportHeight; useEffect(() { if (!hasFetchedReplies) return null; + if (isMember && !isArchived && composerDockHeight.value <= 0) return null; if (!initialTailSettle.isComplete) { previousReplyCount.value = replies.length; initialTailSettle.schedule( @@ -186,12 +189,11 @@ class ThreadDetailPage extends HookConsumerWidget { targetIndex: initialMessageId == null && replies.isNotEmpty ? indexForReply(replies.length - 1) : null, - hiddenBottomFraction: - composerDockHeight.value / MediaQuery.sizeOf(context).height, + hiddenTopFraction: topOverlayFraction, + hiddenBottomFraction: composerDockHeight.value / viewportHeight, ); return null; } - final previous = previousReplyCount.value; previousReplyCount.value = replies.length; if (replies.length <= previous) return null; @@ -219,7 +221,7 @@ class ThreadDetailPage extends HookConsumerWidget { ); }); return null; - }, [hasFetchedReplies, replies.length]); + }, [hasFetchedReplies, replies.length, composerDockHeight.value]); final readState = ref.watch(readStateProvider); final visibleReplyReadKey = replies .map((reply) => '${reply.id}:${reply.createdAt}') diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 79e152c3854..73edd452a90 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -4085,6 +4085,77 @@ void main() { ); }); + testWidgets( + 'cached initial thread settles between the app bar and measured composer', + (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: i == 29 + ? List.filled(33, 'Tall latest reply').join('\n') + : '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 latestReply = find.byKey( + const ValueKey('thread-message-group-reply-29'), + ); + final latestRect = tester.getRect(latestReply); + final context = tester.element(latestReply); + final composerTop = tester + .getTopLeft(find.byKey(const ValueKey('composer-surface'))) + .dy; + expect( + latestRect.top, + greaterThanOrEqualTo(frostedAppBarHeight(context)), + ); + expect(latestRect.bottom, lessThanOrEqualTo(composerTop)); + }, + ); + testWidgets('short initial thread hydration remains top-anchored', ( tester, ) async { From b59f263f5d7c524aa0da050eb90fa7b6f6a48b8c Mon Sep 17 00:00:00 2001 From: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Mon, 10 Aug 2026 15:04:59 -0400 Subject: [PATCH 11/20] fix(mobile): align thread settle to list viewport Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../features/channels/laid_out_viewport.dart | 48 +++ .../features/channels/thread_detail_page.dart | 297 +++++++++--------- .../channels/channel_detail_page_test.dart | 144 +++++++++ 3 files changed, 337 insertions(+), 152 deletions(-) create mode 100644 mobile/lib/features/channels/laid_out_viewport.dart diff --git a/mobile/lib/features/channels/laid_out_viewport.dart b/mobile/lib/features/channels/laid_out_viewport.dart new file mode 100644 index 00000000000..aaf6790f47b --- /dev/null +++ b/mobile/lib/features/channels/laid_out_viewport.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +class LaidOutViewport { + final key = GlobalKey(); + final height = ValueNotifier(0.0); + + void reportAfterLayout() { + WidgetsBinding.instance.addPostFrameCallback((_) { + final renderObject = key.currentContext?.findRenderObject(); + if (renderObject is! RenderBox || !renderObject.hasSize) return; + final nextHeight = renderObject.size.height; + if ((height.value - nextHeight).abs() < 0.5) return; + height.value = nextHeight; + }); + } + + void dispose() => height.dispose(); +} + +class LaidOutViewportReporter extends HookWidget { + final LaidOutViewport viewport; + final Widget child; + + const LaidOutViewportReporter({ + super.key, + required this.viewport, + required this.child, + }); + + @override + Widget build(BuildContext context) { + useEffect(() { + viewport.reportAfterLayout(); + return null; + }, [viewport]); + + return NotificationListener( + onNotification: (_) { + viewport.reportAfterLayout(); + return true; + }, + child: SizeChangedLayoutNotifier( + child: KeyedSubtree(key: viewport.key, child: child), + ), + ); + } +} diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 9c38c5c3f01..a3dbeed713f 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -25,6 +25,7 @@ import 'date_formatters.dart'; import 'day_divider.dart'; import '../profile/user_profile_sheet.dart'; import 'initial_thread_tail_settle.dart'; +import 'laid_out_viewport.dart'; import 'message_actions.dart'; import 'message_long_press_region.dart'; import 'message_content.dart'; @@ -63,20 +64,12 @@ class ThreadDetailPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final composerDockHeight = useState(0.0); final sendMessage = ref.read(sendMessageProvider); - // Relay thread queries are keyed by the outermost root, even when this - // page displays a nested branch. Query that root, then select this head's - // direct children from the returned subtree below. final queryRootId = threadHead.rootId ?? threadHead.id; final repliesState = ref.watch( threadRepliesWithLocalProvider( ThreadRepliesArgs(channelId: channelId, rootId: queryRootId), ), ); - // The thread query is one-shot and asks only for content kinds, so a - // reaction, edit, or deletion that lands while the thread is open never - // reaches it — a new pill (and its burst) only showed up after leaving and - // re-entering, which refetched. The channel socket already receives those - // events, so union the two sources and format once. final liveChannelEvents = ref.watch(channelMessagesProvider(channelId)).value ?? const []; @@ -95,18 +88,12 @@ class ThreadDetailPage extends HookConsumerWidget { final allMsgs = fetchedReplies == null ? allMessages : [ - // Only fall back to the pushed-route snapshot when neither source - // carries the head, and no live deletion has suppressed it. That - // keeps a temporarily unavailable head visible without restoring - // a head that was deleted while this page was open. if (!liveDeletionHidesHead && !fetchedReplies.any((message) => message.id == threadHead.id)) threadHead, ...fetchedReplies, ]; - // Index all messages by parentId so we can find direct children of any - // message and compute thread summaries for nested threads. final childrenByParent = >{}; for (final msg in allMsgs) { final pid = msg.parentId; @@ -117,6 +104,8 @@ class ThreadDetailPage extends HookConsumerWidget { final replies = childrenByParent[threadHead.id] ?? const []; final itemScrollController = useMemoized(ItemScrollController.new); final itemPositionsListener = useMemoized(ItemPositionsListener.create); + final listViewport = useMemoized(LaidOutViewport.new); + useEffect(() => listViewport.dispose, [listViewport]); final didJumpToInitialMessage = useRef(false); final followsThreadTail = useRef(false); final userOptedOutOfTailFollow = useRef(false); @@ -150,8 +139,6 @@ class ThreadDetailPage extends HookConsumerWidget { useEffect(() { final messageId = initialMessageId; - // Wait for the authoritative thread query before consuming the one-shot - // jump; the fallback main-timeline list can contain only the linked reply. if (messageId == null || fetchedReplies == null) return null; final chronologicalIndex = replies.indexWhere( (reply) => reply.id == messageId, @@ -175,11 +162,14 @@ class ThreadDetailPage extends HookConsumerWidget { final hasFetchedReplies = fetchedReplies != null; final initialTailSettle = useMemoized(InitialThreadTailSettle.new); final previousReplyCount = useRef(replies.length); - final viewportHeight = MediaQuery.sizeOf(context).height; + final viewportHeight = useListenable(listViewport.height).value; final topOverlayFraction = frostedAppBarHeight(context) / viewportHeight; + final settleGeometry = (composerDockHeight.value, viewportHeight); useEffect(() { - if (!hasFetchedReplies) return null; - if (isMember && !isArchived && composerDockHeight.value <= 0) return null; + if (!hasFetchedReplies || viewportHeight <= 0) return null; + if (isMember && !isArchived && composerDockHeight.value <= 0) { + return null; + } if (!initialTailSettle.isComplete) { previousReplyCount.value = replies.length; initialTailSettle.schedule( @@ -221,7 +211,7 @@ class ThreadDetailPage extends HookConsumerWidget { ); }); return null; - }, [hasFetchedReplies, replies.length, composerDockHeight.value]); + }, [hasFetchedReplies, replies.length, settleGeometry]); final readState = ref.watch(readStateProvider); final visibleReplyReadKey = replies .map((reply) => '${reply.id}:${reply.createdAt}') @@ -259,6 +249,7 @@ class ThreadDetailPage extends HookConsumerWidget { final effectiveRootId = threadHead.rootId ?? threadHead.id; void updateComposerDockHeight(double height) { + listViewport.reportAfterLayout(); final previousHeight = composerDockHeight.value; final heightDelta = height - previousHeight; if (heightDelta.abs() < 0.5) return; @@ -281,7 +272,7 @@ class ThreadDetailPage extends HookConsumerWidget { if (lastPosition == null) return; final targetAlignment = (pendingTailAlignment.value ?? lastPosition.itemLeadingEdge) - - (heightDelta / MediaQuery.sizeOf(context).height); + (heightDelta / viewportHeight); pendingTailAlignment.value = targetAlignment; WidgetsBinding.instance.addPostFrameCallback((_) { @@ -293,11 +284,8 @@ class ThreadDetailPage extends HookConsumerWidget { }); } - // Composer size changes and keyboard metrics changes are independent: - // the dock grows first, then the Scaffold's viewport shrinks once the - // keyboard appears. Re-align after that latter layout pass too, but only - // while the user was already following the thread tail. void realignThreadTailAfterMetricsChange() { + listViewport.reportAfterLayout(); final shouldFollowTail = !userOptedOutOfTailFollow.value && (followsThreadTail.value || threadTailIsVisible()); @@ -330,7 +318,6 @@ class ThreadDetailPage extends HookConsumerWidget { return () => WidgetsBinding.instance.removeObserver(observer); }, [itemScrollController, replies.length]); - // Channel names for message content rendering. final channelsAsync = ref.watch(channelsProvider); final channelNamesMap = {}; channelsAsync.whenData((channels) { @@ -350,150 +337,156 @@ class ThreadDetailPage extends HookConsumerWidget { Column( children: [ Expanded( - child: KeyboardDismissOnDrag( - onUserScrollStart: () { - userOptedOutOfTailFollow.value = true; - followsThreadTail.value = false; - pendingTailAlignment.value = null; - }, - onUserScrollEnd: () { - if (!threadTailIsVisible()) return; - userOptedOutOfTailFollow.value = false; - followsThreadTail.value = true; - }, - child: ScrollablePositionedList.builder( - key: const ValueKey('thread-message-list'), - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener, - padding: EdgeInsets.only( - left: Grid.gutter, - right: Grid.gutter, - top: frostedAppBarHeight(context), - bottom: Grid.xs + composerDockHeight.value, - ), - itemCount: replies.length + 1, // +1 for thread head - itemBuilder: (context, index) { - if (index == headIndex) { - if (liveDeletionHidesHead) { - return const Padding( - key: ValueKey('thread-message-deleted'), - padding: EdgeInsets.only(bottom: Grid.xs), - child: Text('This message was deleted'), + child: LaidOutViewportReporter( + viewport: listViewport, + child: KeyboardDismissOnDrag( + onUserScrollStart: () { + userOptedOutOfTailFollow.value = true; + followsThreadTail.value = false; + pendingTailAlignment.value = null; + }, + onUserScrollEnd: () { + if (!threadTailIsVisible()) return; + userOptedOutOfTailFollow.value = false; + followsThreadTail.value = true; + }, + child: ScrollablePositionedList.builder( + key: const ValueKey('thread-message-list'), + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + padding: EdgeInsets.only( + left: Grid.gutter, + right: Grid.gutter, + top: frostedAppBarHeight(context), + bottom: Grid.xs + composerDockHeight.value, + ), + itemCount: replies.length + 1, // +1 for thread head + itemBuilder: (context, index) { + if (index == headIndex) { + if (liveDeletionHidesHead) { + return const Padding( + key: ValueKey('thread-message-deleted'), + padding: EdgeInsets.only(bottom: Grid.xs), + child: Text('This message was deleted'), + ); + } + return Padding( + key: ValueKey( + 'thread-message-group-${liveHead.id}', + ), + padding: const EdgeInsets.only(bottom: Grid.xs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DayDivider( + label: formatDayHeading(liveHead.createdAt), + ), + _ThreadMessage( + message: liveHead, + channelNames: channelNamesMap, + channelId: channelId, + currentPubkey: currentPubkey, + showAuthor: true, + isHighlighted: + liveHead.id == initialMessageId, + allMessages: allMsgs, + isMember: isMember, + isArchived: isArchived, + isThreadHead: true, + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: Grid.xxs, + ), + child: Row( + children: [ + Text( + '${replies.length} ${replies.length == 1 ? 'reply' : 'replies'}', + style: context.textTheme.labelMedium + ?.copyWith( + color: context + .colors + .onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: Divider( + color: context.colors.outlineVariant, + ), + ), + ], + ), + ), + ], + ), ); } + + final chronIdx = index - 1; + final reply = replies[chronIdx]; + final prevReply = chronIdx > 0 + ? replies[chronIdx - 1] + : null; + final previousMessage = prevReply ?? liveHead; + final showDayDivider = !isSameDay( + previousMessage.createdAt, + reply.createdAt, + ); + final showAuthor = + prevReply == null || + showDayDivider || + prevReply.pubkey.toLowerCase() != + reply.pubkey.toLowerCase() || + (reply.createdAt - prevReply.createdAt) > 300; + + // Check if this reply itself has children (nested thread). + final nestedChildren = childrenByParent[reply.id]; + final nestedSummary = + nestedChildren != null && nestedChildren.isNotEmpty + ? _buildNestedSummary(reply.id, nestedChildren) + : null; + return Padding( - key: ValueKey('thread-message-group-${liveHead.id}'), - padding: const EdgeInsets.only(bottom: Grid.xs), + key: ValueKey('thread-message-group-${reply.id}'), + // Tail spacing comes from the list's own bottom padding now + // that the list runs top-down; the reversed list used to + // need it here because item 0 sat against the composer. + padding: EdgeInsets.zero, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - DayDivider( - label: formatDayHeading(liveHead.createdAt), - ), + if (showDayDivider) + DayDivider( + label: formatDayHeading(reply.createdAt), + ), _ThreadMessage( - message: liveHead, + message: reply, channelNames: channelNamesMap, channelId: channelId, currentPubkey: currentPubkey, - showAuthor: true, - isHighlighted: liveHead.id == initialMessageId, + showAuthor: showAuthor, + isHighlighted: reply.id == initialMessageId, allMessages: allMsgs, isMember: isMember, isArchived: isArchived, - isThreadHead: true, ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: Grid.xxs, - ), - child: Row( - children: [ - Text( - '${replies.length} ${replies.length == 1 ? 'reply' : 'replies'}', - style: context.textTheme.labelMedium - ?.copyWith( - color: - context.colors.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(width: Grid.xxs), - Expanded( - child: Divider( - color: context.colors.outlineVariant, - ), - ), - ], + if (nestedSummary != null) + _NestedThreadSummaryRow( + summary: nestedSummary, + replyMessage: reply, + allMessages: allMsgs, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, ), - ), ], ), ); - } - - // Chronological list: index 1 = oldest reply. - final chronIdx = index - 1; - final reply = replies[chronIdx]; - final prevReply = chronIdx > 0 - ? replies[chronIdx - 1] - : null; - final previousMessage = prevReply ?? liveHead; - final showDayDivider = !isSameDay( - previousMessage.createdAt, - reply.createdAt, - ); - final showAuthor = - prevReply == null || - showDayDivider || - prevReply.pubkey.toLowerCase() != - reply.pubkey.toLowerCase() || - (reply.createdAt - prevReply.createdAt) > 300; - - // Check if this reply itself has children (nested thread). - final nestedChildren = childrenByParent[reply.id]; - final nestedSummary = - nestedChildren != null && nestedChildren.isNotEmpty - ? _buildNestedSummary(reply.id, nestedChildren) - : null; - - return Padding( - key: ValueKey('thread-message-group-${reply.id}'), - // Tail spacing comes from the list's own bottom padding now - // that the list runs top-down; the reversed list used to - // need it here because item 0 sat against the composer. - padding: EdgeInsets.zero, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showDayDivider) - DayDivider( - label: formatDayHeading(reply.createdAt), - ), - _ThreadMessage( - message: reply, - channelNames: channelNamesMap, - channelId: channelId, - currentPubkey: currentPubkey, - showAuthor: showAuthor, - isHighlighted: reply.id == initialMessageId, - allMessages: allMsgs, - isMember: isMember, - isArchived: isArchived, - ), - if (nestedSummary != null) - _NestedThreadSummaryRow( - summary: nestedSummary, - replyMessage: reply, - allMessages: allMsgs, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - ], - ), - ); - }, + }, + ), ), ), ), diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 73edd452a90..441d13fa93e 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -4156,6 +4156,150 @@ void main() { }, ); + testWidgets('read-only cached thread still settles on its tail', ( + tester, + ) async { + 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: false, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + + final latestReply = find.byKey( + const ValueKey('thread-message-group-reply-29'), + ); + expect(latestReply, findsOneWidget); + expect( + tester.getTopLeft(latestReply).dy, + greaterThanOrEqualTo(frostedAppBarHeight(tester.element(latestReply))), + ); + }); + + testWidgets( + 'keyboard-open initial hydration settles within the list viewport', + (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: i == 29 + ? List.filled(15, 'Tall latest reply').join('\n') + : 'Reply $i', + createdAt: 1100 + i, + 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(); + + await tester.tap(find.text('Reply in thread…').hitTestable()); + await tester.pumpAndSettle(); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pumpAndSettle(); + + final list = find.byKey(const ValueKey('thread-message-list')); + final listHeight = tester.getSize(list).height; + final mediaQueryHeight = MediaQuery.sizeOf(tester.element(list)).height; + expect(listHeight, lessThan(mediaQueryHeight)); + + completer.complete(replies); + await tester.pumpAndSettle(); + + final latestReply = find.byKey( + const ValueKey('thread-message-group-reply-29'), + ); + final latestRect = tester.getRect(latestReply); + final context = tester.element(latestReply); + final composerTop = tester + .getTopLeft(find.byKey(const ValueKey('composer-surface'))) + .dy; + expect( + latestRect.top, + greaterThanOrEqualTo(frostedAppBarHeight(context)), + ); + expect(latestRect.bottom, lessThanOrEqualTo(composerTop)); + }, + ); + testWidgets('short initial thread hydration remains top-anchored', ( tester, ) async { From 59a05a867d2d660645187b633187f97ff20e3a41 Mon Sep 17 00:00:00 2001 From: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Mon, 10 Aug 2026 15:34:13 -0400 Subject: [PATCH 12/20] docs(mobile): document laid-out viewport helper Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../features/channels/laid_out_viewport.dart | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/mobile/lib/features/channels/laid_out_viewport.dart b/mobile/lib/features/channels/laid_out_viewport.dart index aaf6790f47b..12fa4a2f88e 100644 --- a/mobile/lib/features/channels/laid_out_viewport.dart +++ b/mobile/lib/features/channels/laid_out_viewport.dart @@ -1,10 +1,22 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +/// Tracks the height of a viewport after Flutter has laid it out. +/// +/// Create one instance for a viewport, pass it to [LaidOutViewportReporter], +/// listen to [height] for measurements, and call [dispose] when the owner is +/// removed. Measurements are reported after the frame that performed layout. class LaidOutViewport { + /// Key used by [LaidOutViewportReporter] to locate the viewport render box. final key = GlobalKey(); + + /// The latest laid-out viewport height, or zero before the first report. final height = ValueNotifier(0.0); + /// Schedules a viewport measurement after the current frame is laid out. + /// + /// Reports only changes of at least half a logical pixel. If the viewport is + /// not mounted or has not been laid out, the scheduled report does nothing. void reportAfterLayout() { WidgetsBinding.instance.addPostFrameCallback((_) { final renderObject = key.currentContext?.findRenderObject(); @@ -15,13 +27,23 @@ class LaidOutViewport { }); } + /// Releases the [height] notifier owned by this viewport. void dispose() => height.dispose(); } +/// Reports the laid-out height of [child] through [viewport]. +/// +/// Reports once after initial layout and again whenever the child's layout size +/// changes. The owner must keep [viewport] stable across rebuilds and dispose it +/// after this reporter is removed. class LaidOutViewportReporter extends HookWidget { + /// Receives the laid-out height measurements. final LaidOutViewport viewport; + + /// The widget whose viewport height is measured. final Widget child; + /// Creates a reporter for [child] backed by [viewport]. const LaidOutViewportReporter({ super.key, required this.viewport, From 64a38cc93084c8a70398671cb2a387e9bc8df3b0 Mon Sep 17 00:00:00 2001 From: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Mon, 10 Aug 2026 15:58:04 -0400 Subject: [PATCH 13/20] fix(mobile): abandon initial thread settle on drag Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../channels/initial_thread_tail_settle.dart | 9 + .../features/channels/thread_detail_page.dart | 1 + .../channels/channel_detail_page_test.dart | 168 ++++++++++++++++++ 3 files changed, 178 insertions(+) diff --git a/mobile/lib/features/channels/initial_thread_tail_settle.dart b/mobile/lib/features/channels/initial_thread_tail_settle.dart index c8af8098834..3e6fa271ba8 100644 --- a/mobile/lib/features/channels/initial_thread_tail_settle.dart +++ b/mobile/lib/features/channels/initial_thread_tail_settle.dart @@ -15,6 +15,15 @@ class InitialThreadTailSettle { /// visible after hydration has settled, or the scheduled scroll finishes. bool get isComplete => _isComplete; + /// Permanently abandons initial settling and invalidates queued callbacks. + /// + /// This is terminal: later scheduling remains disabled even if the user + /// returns to the tail and resumes ordinary follow behavior. + void abandon() { + _generation++; + _isComplete = true; + } + /// Schedules a settle after each hydrated thread layout until [isComplete]. /// /// A later schedule replaces an earlier target while replies are still diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index a3dbeed713f..a7dfdc2b90c 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -341,6 +341,7 @@ class ThreadDetailPage extends HookConsumerWidget { viewport: listViewport, child: KeyboardDismissOnDrag( onUserScrollStart: () { + initialTailSettle.abandon(); userOptedOutOfTailFollow.value = true; followsThreadTail.value = false; pendingTailAlignment.value = null; diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 441d13fa93e..2a196d052d2 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -33,6 +33,7 @@ import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/frosted_app_bar.dart'; +import 'package:buzz/shared/widgets/keyboard_dismiss_on_drag.dart'; import 'package:buzz/shared/widgets/skeleton.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -4215,6 +4216,173 @@ void main() { ); }); + testWidgets( + 'user drag abandons pending hydration settle until returning to tail', + (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 < 35; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'bob', + content: 'Reply $i', + createdAt: 1100 + i, + 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 provisional = formatTimeline([rootEvent, ...replies.take(30)]); + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: provisional.first, + allMessages: provisional, + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + + final list = find.byKey(const ValueKey('thread-message-list')); + await tester.drag(list, const Offset(0, -100)); + await tester.pumpAndSettle(); + final anchor = find.byKey( + const ValueKey('thread-message-group-reply-10'), + ); + final anchorTop = tester.getTopLeft(anchor).dy; + + completer.complete(replies); + await tester.pumpAndSettle(); + + expect(anchor, findsOneWidget); + expect(tester.getTopLeft(anchor).dy, closeTo(anchorTop, 0.5)); + expect( + find.byKey(const ValueKey('thread-message-group-reply-34')), + findsNothing, + ); + + for (var i = 0; i < 12; i++) { + await tester.drag(list, const Offset(0, -100)); + await tester.pumpAndSettle(); + } + final latestReply = find.byKey( + const ValueKey('thread-message-group-reply-34'), + ); + expect(latestReply, findsOneWidget); + + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pumpAndSettle(); + + expect( + tester.getBottomLeft(latestReply).dy, + lessThanOrEqualTo( + tester + .getTopLeft(find.byKey(const ValueKey('composer-surface'))) + .dy, + ), + ); + }, + ); + + testWidgets('user drag invalidates an already queued hydration settle', ( + tester, + ) async { + final rootEvent = _textMsg( + id: 'thread-root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final replies = [ + for (var i = 0; i < 35; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'bob', + content: 'Reply $i', + createdAt: 1100 + i, + 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 provisional = formatTimeline([rootEvent, ...replies.take(30)]); + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: provisional.first, + allMessages: provisional, + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + + final anchor = find.byKey( + const ValueKey('thread-message-group-thread-root'), + ); + final anchorTop = tester.getTopLeft(anchor).dy; + completer.complete(replies); + await tester.pump(); + + // Hydration has scheduled the settle's second post-frame callback. A + // drag starts before another frame can run it. + tester + .widget(find.byType(KeyboardDismissOnDrag)) + .onUserScrollStart!(); + await tester.pumpAndSettle(); + + expect(anchor, findsOneWidget); + expect(tester.getTopLeft(anchor).dy, closeTo(anchorTop, 0.5)); + expect( + find.byKey(const ValueKey('thread-message-group-reply-34')), + findsNothing, + ); + }); + testWidgets( 'keyboard-open initial hydration settles within the list viewport', (tester) async { From a789bd633d2aabb6c71d763036de02c1641c67aa Mon Sep 17 00:00:00 2001 From: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Mon, 10 Aug 2026 16:49:58 -0400 Subject: [PATCH 14/20] fix(mobile): scope drag handling to primary list Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../widgets/keyboard_dismiss_on_drag.dart | 6 + .../keyboard_dismiss_on_drag_test.dart | 151 +++++++++++++++++- 2 files changed, 150 insertions(+), 7 deletions(-) diff --git a/mobile/lib/shared/widgets/keyboard_dismiss_on_drag.dart b/mobile/lib/shared/widgets/keyboard_dismiss_on_drag.dart index 6356f76dcf9..c38608c3cc6 100644 --- a/mobile/lib/shared/widgets/keyboard_dismiss_on_drag.dart +++ b/mobile/lib/shared/widgets/keyboard_dismiss_on_drag.dart @@ -47,6 +47,12 @@ class KeyboardDismissOnDrag extends HookWidget { final userScrollInProgress = useRef(false); bool handle(ScrollNotification notification) { + // This wrapper owns the directly wrapped message list. Notifications from + // scrollables inside a message (code blocks, media, reactions, and any + // future vertical descendants) bubble through here at a greater depth and + // must not affect the primary list's drag lifecycle or dismissal state. + if (notification.depth != 0) return false; + if (notification is ScrollStartNotification) { userScrollInProgress.value = notification.dragDetails != null; if (userScrollInProgress.value) onUserScrollStart?.call(); diff --git a/mobile/test/shared/widgets/keyboard_dismiss_on_drag_test.dart b/mobile/test/shared/widgets/keyboard_dismiss_on_drag_test.dart index e228c5226e3..55f6281e50f 100644 --- a/mobile/test/shared/widgets/keyboard_dismiss_on_drag_test.dart +++ b/mobile/test/shared/widgets/keyboard_dismiss_on_drag_test.dart @@ -8,6 +8,7 @@ Widget _testable({ required FocusNode focusNode, VoidCallback? onUserScrollStart, VoidCallback? onUserScrollEnd, + Widget? scrollChild, }) { return MaterialApp( home: Scaffold( @@ -18,12 +19,14 @@ Widget _testable({ child: KeyboardDismissOnDrag( onUserScrollStart: onUserScrollStart, onUserScrollEnd: onUserScrollEnd, - child: ListView( - children: [ - for (var i = 0; i < 40; i++) - SizedBox(height: 60, child: Text('row $i')), - ], - ), + child: + scrollChild ?? + ListView( + children: [ + for (var i = 0; i < 40; i++) + SizedBox(height: 60, child: Text('row $i')), + ], + ), ), ), ], @@ -64,7 +67,15 @@ void main() { final focusNode = FocusNode(); addTearDown(focusNode.dispose); _raiseKeyboard(tester); - await tester.pumpWidget(_testable(focusNode: focusNode)); + var starts = 0; + var ends = 0; + await tester.pumpWidget( + _testable( + focusNode: focusNode, + onUserScrollStart: () => starts += 1, + onUserScrollEnd: () => ends += 1, + ), + ); focusNode.requestFocus(); await tester.pump(); expect(focusNode.hasFocus, isTrue); @@ -76,6 +87,8 @@ void main() { // `ScrollUpdateNotification`, and the one a focused composer is usually // sitting in. expect(focusNode.hasFocus, isFalse); + expect(starts, 1); + expect(ends, 1); }); testWidgets('an ordinary downward scroll dismisses too', (tester) async { @@ -144,6 +157,130 @@ void main() { expect(userScrollEnds, 1); }); + testWidgets('nested horizontal drag is outside the primary list boundary', ( + tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + _raiseKeyboard(tester); + var starts = 0; + var ends = 0; + await tester.pumpWidget( + _testable( + focusNode: focusNode, + onUserScrollStart: () => starts += 1, + onUserScrollEnd: () => ends += 1, + scrollChild: ListView( + children: [ + SizedBox( + height: 80, + child: SingleChildScrollView( + key: const ValueKey('nested-horizontal'), + scrollDirection: Axis.horizontal, + child: const SizedBox( + width: 1600, + child: Text('nested horizontal content'), + ), + ), + ), + const SizedBox(height: 1200), + ], + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + await tester.drag( + find.byKey(const ValueKey('nested-horizontal')), + const Offset(-200, 60), + ); + await tester.pumpAndSettle(); + + expect(starts, 0); + expect(ends, 0); + expect(focusNode.hasFocus, isTrue); + }); + + testWidgets('nested vertical drag is outside the primary list boundary', ( + tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + _raiseKeyboard(tester); + var starts = 0; + var ends = 0; + await tester.pumpWidget( + _testable( + focusNode: focusNode, + onUserScrollStart: () => starts += 1, + onUserScrollEnd: () => ends += 1, + scrollChild: ListView( + children: [ + SizedBox( + height: 180, + child: ListView( + key: const ValueKey('nested-vertical'), + children: const [ + SizedBox( + height: 900, + child: Text('nested vertical content'), + ), + ], + ), + ), + const SizedBox(height: 1200), + ], + ), + ), + ); + focusNode.requestFocus(); + await tester.pump(); + + await tester.drag( + find.byKey(const ValueKey('nested-vertical')), + const Offset(0, -100), + ); + await tester.pumpAndSettle(); + + expect(starts, 0); + expect(ends, 0); + expect(focusNode.hasFocus, isTrue); + }); + + testWidgets('programmatic scroll reports no user lifecycle callbacks', ( + tester, + ) async { + final focusNode = FocusNode(); + final controller = ScrollController(); + addTearDown(focusNode.dispose); + addTearDown(controller.dispose); + var starts = 0; + var ends = 0; + await tester.pumpWidget( + _testable( + focusNode: focusNode, + onUserScrollStart: () => starts += 1, + onUserScrollEnd: () => ends += 1, + scrollChild: ListView( + controller: controller, + children: const [SizedBox(height: 2000)], + ), + ), + ); + + final animation = controller.animateTo( + 300, + duration: const Duration(milliseconds: 100), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); + await animation; + + expect(starts, 0); + expect(ends, 0); + }); + testWidgets('an upward drag never dismisses, however far it goes', ( tester, ) async { From 1559fa370c42d627f5ba983b83c73c5133ca40d1 Mon Sep 17 00:00:00 2001 From: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Mon, 10 Aug 2026 17:27:16 -0400 Subject: [PATCH 15/20] fix(mobile): serialize thread tail intent Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../channels/channel_typing_indicator.dart | 26 ++ .../features/channels/thread_detail_page.dart | 140 ++++---- .../features/channels/thread_tail_intent.dart | 30 ++ .../channels/channel_detail_page_test.dart | 309 +++++++++++++++++- 4 files changed, 437 insertions(+), 68 deletions(-) create mode 100644 mobile/lib/features/channels/thread_tail_intent.dart diff --git a/mobile/lib/features/channels/channel_typing_indicator.dart b/mobile/lib/features/channels/channel_typing_indicator.dart index 0543f13b065..daf56188293 100644 --- a/mobile/lib/features/channels/channel_typing_indicator.dart +++ b/mobile/lib/features/channels/channel_typing_indicator.dart @@ -145,3 +145,29 @@ class _TypingTextShimmer extends HookWidget { ); } } + +/// Thread-scoped typing status with optional size animation. +class ThreadTypingIndicator extends StatelessWidget { + final List entries; + final bool animated; + + const ThreadTypingIndicator({ + super.key, + required this.entries, + this.animated = true, + }); + + @override + Widget build(BuildContext context) { + final child = entries.isEmpty + ? const SizedBox.shrink() + : ChannelTypingIndicator(entries: entries); + if (!animated || MediaQuery.disableAnimationsOf(context)) return child; + return AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + alignment: Alignment.bottomCenter, + child: child, + ); + } +} diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index a7dfdc2b90c..935e67e98ec 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -18,6 +18,7 @@ import 'channel_messages_provider.dart'; import 'channel_typing_provider.dart'; import 'channel_typing_indicator.dart'; import 'thread_replies_provider.dart'; +import 'thread_tail_intent.dart'; import 'channels_provider.dart'; import 'compose_bar.dart'; import 'composer_dock_size_reporter.dart'; @@ -109,8 +110,8 @@ class ThreadDetailPage extends HookConsumerWidget { final didJumpToInitialMessage = useRef(false); final followsThreadTail = useRef(false); final userOptedOutOfTailFollow = useRef(false); + final tailIntent = useMemoized(ThreadTailIntent.new); final pendingTailAlignment = useRef(null); - final tailRealignmentQueued = useRef(false); const headIndex = 0; int indexForReply(int chronologicalIndex) => chronologicalIndex + 1; @@ -151,6 +152,7 @@ class ThreadDetailPage extends HookConsumerWidget { if (targetIndex == null || didJumpToInitialMessage.value) return null; WidgetsBinding.instance.addPostFrameCallback((_) { if (!context.mounted || !itemScrollController.isAttached) return; + tailIntent.detach(); followsThreadTail.value = false; pendingTailAlignment.value = null; itemScrollController.jumpTo(index: targetIndex, alignment: 0.35); @@ -163,8 +165,51 @@ class ThreadDetailPage extends HookConsumerWidget { final initialTailSettle = useMemoized(InitialThreadTailSettle.new); final previousReplyCount = useRef(replies.length); final viewportHeight = useListenable(listViewport.height).value; + final previousViewportHeight = useRef(viewportHeight); final topOverlayFraction = frostedAppBarHeight(context) / viewportHeight; final settleGeometry = (composerDockHeight.value, viewportHeight); + bool currentIntentAllowsTailMutation({bool allowIdleDetached = false}) { + if (tailIntent.isDragging) return false; + if (allowIdleDetached) return true; + return !userOptedOutOfTailFollow.value && + (followsThreadTail.value || threadTailIsVisible()); + } + + void queueTailRealignment({ + bool allowIdleDetached = false, + bool animate = true, + }) { + if (!currentIntentAllowsTailMutation( + allowIdleDetached: allowIdleDetached, + )) { + return; + } + if (!allowIdleDetached) followsThreadTail.value = true; + tailIntent.schedule( + allowed: true, + revalidate: () => + context.mounted && + itemScrollController.isAttached && + currentIntentAllowsTailMutation( + allowIdleDetached: allowIdleDetached, + ), + action: () { + final lastIndex = replies.isEmpty + ? headIndex + : indexForReply(replies.length - 1); + if (animate) { + itemScrollController.scrollTo( + index: lastIndex, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + ); + } else { + itemScrollController.jumpTo(index: lastIndex); + } + }, + ); + } + useEffect(() { if (!hasFetchedReplies || viewportHeight <= 0) return null; if (isMember && !isArchived && composerDockHeight.value <= 0) { @@ -172,6 +217,7 @@ class ThreadDetailPage extends HookConsumerWidget { } if (!initialTailSettle.isComplete) { previousReplyCount.value = replies.length; + previousViewportHeight.value = viewportHeight; initialTailSettle.schedule( context: context, controller: itemScrollController, @@ -186,9 +232,14 @@ class ThreadDetailPage extends HookConsumerWidget { } final previous = previousReplyCount.value; previousReplyCount.value = replies.length; - if (replies.length <= previous) return null; + final viewportChanged = + (viewportHeight - previousViewportHeight.value).abs() >= 0.5; + previousViewportHeight.value = viewportHeight; + if (replies.length <= previous) { + if (viewportChanged) queueTailRealignment(animate: false); + return null; + } final positions = itemPositionsListener.itemPositions.value; - final lastIndex = indexForReply(replies.length - 1); final previousLastIndex = previous == 0 ? headIndex : indexForReply(previous - 1); @@ -201,15 +252,11 @@ class ThreadDetailPage extends HookConsumerWidget { replies .skip(previous) .any((reply) => reply.pubkey.toLowerCase() == localPubkey); - if (!wasAtTail && !hasNewLocalReply) return null; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!context.mounted || !itemScrollController.isAttached) return; - itemScrollController.scrollTo( - index: lastIndex, - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - ); - }); + if (tailIntent.isDragging) return null; + if (!hasNewLocalReply && (userOptedOutOfTailFollow.value || !wasAtTail)) { + return null; + } + queueTailRealignment(allowIdleDetached: hasNewLocalReply); return null; }, [hasFetchedReplies, replies.length, settleGeometry]); final readState = ref.watch(readStateProvider); @@ -229,7 +276,6 @@ class ThreadDetailPage extends HookConsumerWidget { return null; }, [threadHead.id, readState.isReady, visibleReplyReadKey]); - // Thread-scoped typing indicators (exclude self). final allTyping = ref.watch(channelTypingProvider(channelId)); final threadTyping = allTyping .where((e) => e.threadHeadId == threadHead.id) @@ -240,12 +286,9 @@ class ThreadDetailPage extends HookConsumerWidget { ) .toList(); - // Resolve thread head from live data (reactions/edits may have changed). final liveHead = allMsgs.where((m) => m.id == threadHead.id).firstOrNull ?? threadHead; - // The root of the entire thread chain. If the current thread head is - // itself a root message its rootId is null, so fall back to its own id. final effectiveRootId = threadHead.rootId ?? threadHead.id; void updateComposerDockHeight(double height) { @@ -275,39 +318,22 @@ class ThreadDetailPage extends HookConsumerWidget { (heightDelta / viewportHeight); pendingTailAlignment.value = targetAlignment; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!context.mounted || !itemScrollController.isAttached) return; - itemScrollController.jumpTo( + tailIntent.schedule( + allowed: true, + revalidate: () => + context.mounted && + itemScrollController.isAttached && + currentIntentAllowsTailMutation(), + action: () => itemScrollController.jumpTo( index: lastIndex, alignment: targetAlignment, - ); - }); + ), + ); } void realignThreadTailAfterMetricsChange() { listViewport.reportAfterLayout(); - final shouldFollowTail = - !userOptedOutOfTailFollow.value && - (followsThreadTail.value || threadTailIsVisible()); - if (!shouldFollowTail || tailRealignmentQueued.value) return; - followsThreadTail.value = true; - tailRealignmentQueued.value = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - tailRealignmentQueued.value = false; - if (!context.mounted || - !itemScrollController.isAttached || - !followsThreadTail.value) { - return; - } - final lastIndex = replies.isEmpty - ? headIndex - : indexForReply(replies.length - 1); - itemScrollController.scrollTo( - index: lastIndex, - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - ); - }); + queueTailRealignment(); } useEffect(() { @@ -342,11 +368,13 @@ class ThreadDetailPage extends HookConsumerWidget { child: KeyboardDismissOnDrag( onUserScrollStart: () { initialTailSettle.abandon(); + tailIntent.beginDrag(); userOptedOutOfTailFollow.value = true; followsThreadTail.value = false; pendingTailAlignment.value = null; }, onUserScrollEnd: () { + tailIntent.endDrag(); if (!threadTailIsVisible()) return; userOptedOutOfTailFollow.value = false; followsThreadTail.value = true; @@ -442,7 +470,6 @@ class ThreadDetailPage extends HookConsumerWidget { reply.pubkey.toLowerCase() || (reply.createdAt - prevReply.createdAt) > 300; - // Check if this reply itself has children (nested thread). final nestedChildren = childrenByParent[reply.id]; final nestedSummary = nestedChildren != null && nestedChildren.isNotEmpty @@ -451,9 +478,6 @@ class ThreadDetailPage extends HookConsumerWidget { return Padding( key: ValueKey('thread-message-group-${reply.id}'), - // Tail spacing comes from the list's own bottom padding now - // that the list runs top-down; the reversed list used to - // need it here because item 0 sat against the composer. padding: EdgeInsets.zero, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -492,16 +516,7 @@ class ThreadDetailPage extends HookConsumerWidget { ), ), if (!isMember || isArchived) - AnimatedSize( - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - alignment: Alignment.bottomCenter, - child: threadTyping.isEmpty - ? const SizedBox.shrink() - : ChannelTypingIndicator(entries: threadTyping), - ), + ThreadTypingIndicator(entries: threadTyping, animated: false), ], ), if (isMember && !isArchived) @@ -513,16 +528,7 @@ class ThreadDetailPage extends HookConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - AnimatedSize( - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - alignment: Alignment.bottomCenter, - child: threadTyping.isEmpty - ? const SizedBox.shrink() - : ChannelTypingIndicator(entries: threadTyping), - ), + ThreadTypingIndicator(entries: threadTyping), ComposeBar( channelId: channelId, hintText: 'Reply in thread\u2026', diff --git a/mobile/lib/features/channels/thread_tail_intent.dart b/mobile/lib/features/channels/thread_tail_intent.dart new file mode 100644 index 00000000000..825f0370cf6 --- /dev/null +++ b/mobile/lib/features/channels/thread_tail_intent.dart @@ -0,0 +1,30 @@ +import 'package:flutter/widgets.dart'; + +/// Serializes deferred tail work behind the latest user scroll intent. +class ThreadTailIntent { + var _generation = 0; + var isDragging = false; + + void detach() => _generation++; + + void beginDrag() { + isDragging = true; + detach(); + } + + void endDrag() => isDragging = false; + + void schedule({ + required bool allowed, + required bool Function() revalidate, + required VoidCallback action, + }) { + if (!allowed) return; + final generation = ++_generation; + WidgetsBinding.instance.addPostFrameCallback((_) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (generation == _generation && revalidate()) action(); + }); + }); + } +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 2a196d052d2..0eece48c777 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -177,6 +177,7 @@ Widget _buildTestable({ ChannelActions Function(Ref ref)? createChannelActions, ReadStateNotifier? readStateNotifier, _FakeMessagesNotifier? messagesNotifier, + _FakeTypingNotifier? typingNotifier, String? canvasContent, String? initialMessageId, String? initialThreadRootId, @@ -198,7 +199,7 @@ Widget _buildTestable({ ).overrideWith(() => fakeMessagesNotifier), channelTypingProvider( _channelId, - ).overrideWith(() => _FakeTypingNotifier(typing)), + ).overrideWith(() => typingNotifier ?? _FakeTypingNotifier(typing)), userCacheProvider.overrideWith( () => userCacheNotifier ?? _FakeUserCacheNotifier(users), ), @@ -4635,6 +4636,310 @@ void main() { }, ); + testWidgets( + 'active primary drag rejects queued remote and local reply tail work', + (tester) async { + final rootEvent = _textMsg( + id: 'thread-root', + pubkey: 'alice', + content: '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'], + ], + ), + ]; + final messagesNotifier = _FakeMessagesNotifier([rootEvent]); + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + messagesNotifier: messagesNotifier, + threadReplies: {'thread-root': replies}, + ), + ); + 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 lifecycle = tester.widget( + find.byType(KeyboardDismissOnDrag), + ); + lifecycle.onUserScrollStart!(); + final anchor = find.byKey( + const ValueKey('thread-message-group-reply-20'), + ); + final anchorTop = tester.getTopLeft(anchor).dy; + final remoteReply = _textMsg( + id: 'reply-remote', + pubkey: 'bob', + content: 'Remote', + createdAt: 1200, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ); + messagesNotifier.setMessages([rootEvent, remoteReply]); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(anchor).dy, closeTo(anchorTop, 0.5)); + final localReply = _textMsg( + id: 'reply-local', + pubkey: 'self', + content: 'Local', + createdAt: 1201, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ); + messagesNotifier.setMessages([rootEvent, remoteReply, localReply]); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(anchor).dy, closeTo(anchorTop, 0.5)); + lifecycle.onUserScrollEnd!(); + }, + ); + + testWidgets('idle detached local reply retains force-visible behavior', ( + tester, + ) async { + final rootEvent = _textMsg( + id: 'thread-root', + pubkey: 'alice', + content: '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'], + ], + ), + ]; + final messagesNotifier = _FakeMessagesNotifier([rootEvent]); + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + messagesNotifier: messagesNotifier, + threadReplies: {'thread-root': replies}, + ), + ); + 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(); + } + final localReply = _textMsg( + id: 'reply-local', + pubkey: 'self', + content: 'Local', + createdAt: 1200, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ); + messagesNotifier.setMessages([rootEvent, localReply]); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('thread-message-group-reply-local')), + findsOneWidget, + ); + }); + + for (final layout in <({String name, bool isMember, bool isArchived})>[ + (name: 'non-member', isMember: false, isArchived: false), + (name: 'archived', isMember: true, isArchived: true), + (name: 'writable member', isMember: true, isArchived: false), + ]) { + testWidgets( + '${layout.name} typing transitions preserve a followed tail', + (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: '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'], + ], + ), + ]; + final typingNotifier = _FakeTypingNotifier(const []); + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + typingNotifier: typingNotifier, + threadReplies: {'thread-root': replies}, + disableAnimations: true, + ), + ); + 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: layout.isMember, + isArchived: layout.isArchived, + ), + ), + ); + await tester.pumpAndSettle(); + final latest = find.byKey( + const ValueKey('thread-message-group-reply-29'), + ); + final list = find.byKey(const ValueKey('thread-message-list')); + final initialHeight = tester.getSize(list).height; + expect(latest, findsOneWidget); + typingNotifier.setEntries(const [ + TypingEntry( + pubkey: 'bob', + threadHeadId: 'thread-root', + expiresAtMs: 9999999999999, + ), + ]); + await tester.pumpAndSettle(); + expect(latest, findsOneWidget); + if (!layout.isMember || layout.isArchived) { + expect(tester.getSize(list).height, lessThan(initialHeight)); + } + typingNotifier.setEntries(const []); + await tester.pumpAndSettle(); + expect(latest, findsOneWidget); + expect(tester.getSize(list).height, closeTo(initialHeight, 0.5)); + }, + ); + + testWidgets( + '${layout.name} typing transitions preserve a detached anchor', + (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: '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'], + ], + ), + ]; + final typingNotifier = _FakeTypingNotifier(const []); + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + typingNotifier: typingNotifier, + threadReplies: {'thread-root': replies}, + disableAnimations: true, + ), + ); + 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: layout.isMember, + isArchived: layout.isArchived, + ), + ), + ); + 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(); + } + final listRect = tester.getRect(list); + final anchor = + [ + for (var i = 0; i < replies.length; i++) + find.byKey(ValueKey('thread-message-group-reply-$i')), + ].firstWhere( + (candidate) => + candidate.evaluate().length == 1 && + listRect.overlaps(tester.getRect(candidate)), + ); + final anchorTop = tester.getTopLeft(anchor).dy; + typingNotifier.setEntries(const [ + TypingEntry( + pubkey: 'bob', + threadHeadId: 'thread-root', + expiresAtMs: 9999999999999, + ), + ]); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(anchor).dy, closeTo(anchorTop, 0.5)); + typingNotifier.setEntries(const []); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(anchor).dy, closeTo(anchorTop, 0.5)); + }, + ); + } + testWidgets( 'dragging away opts out, then returning to the tail resumes keyboard realignment', (tester) async { @@ -5136,6 +5441,8 @@ class _FakeTypingNotifier extends ChannelTypingNotifier { @override List build() => _entries; + + void setEntries(List entries) => state = entries; } class _SynchronousReadStateNotifier extends ReadStateNotifier { From e9a853a3c1e37a78ce8335e42b5ed84e6845f1da Mon Sep 17 00:00:00 2001 From: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Mon, 10 Aug 2026 18:23:39 -0400 Subject: [PATCH 16/20] fix(mobile): align followed thread tails Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../channels/channel_typing_indicator.dart | 26 --- .../channels/thread_detail_helpers.dart | 64 +++++++ .../features/channels/thread_detail_page.dart | 44 ++--- .../features/channels/thread_tail_intent.dart | 30 ---- .../channels/channel_detail_page_test.dart | 161 ++++++++++-------- 5 files changed, 173 insertions(+), 152 deletions(-) create mode 100644 mobile/lib/features/channels/thread_detail_helpers.dart delete mode 100644 mobile/lib/features/channels/thread_tail_intent.dart diff --git a/mobile/lib/features/channels/channel_typing_indicator.dart b/mobile/lib/features/channels/channel_typing_indicator.dart index daf56188293..0543f13b065 100644 --- a/mobile/lib/features/channels/channel_typing_indicator.dart +++ b/mobile/lib/features/channels/channel_typing_indicator.dart @@ -145,29 +145,3 @@ class _TypingTextShimmer extends HookWidget { ); } } - -/// Thread-scoped typing status with optional size animation. -class ThreadTypingIndicator extends StatelessWidget { - final List entries; - final bool animated; - - const ThreadTypingIndicator({ - super.key, - required this.entries, - this.animated = true, - }); - - @override - Widget build(BuildContext context) { - final child = entries.isEmpty - ? const SizedBox.shrink() - : ChannelTypingIndicator(entries: entries); - if (!animated || MediaQuery.disableAnimationsOf(context)) return child; - return AnimatedSize( - duration: const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, - alignment: Alignment.bottomCenter, - child: child, - ); - } -} diff --git a/mobile/lib/features/channels/thread_detail_helpers.dart b/mobile/lib/features/channels/thread_detail_helpers.dart new file mode 100644 index 00000000000..bb916f409fe --- /dev/null +++ b/mobile/lib/features/channels/thread_detail_helpers.dart @@ -0,0 +1,64 @@ +part of 'thread_detail_page.dart'; + +int _threadTailIndex(int replyCount) => replyCount; + +class _ThreadTailMetricsObserver with WidgetsBindingObserver { + final VoidCallback onMetricsChanged; + + _ThreadTailMetricsObserver({required this.onMetricsChanged}); + + @override + void didChangeMetrics() => onMetricsChanged(); +} + +/// Serializes deferred tail work behind the latest user scroll intent. +class _ThreadTailIntent { + var _generation = 0; + var isDragging = false; + + void detach() => _generation++; + + void beginDrag() { + isDragging = true; + detach(); + } + + void endDrag() => isDragging = false; + + void schedule({ + required bool allowed, + required bool Function() revalidate, + required VoidCallback action, + }) { + if (!allowed) return; + final generation = ++_generation; + WidgetsBinding.instance.addPostFrameCallback((_) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (generation == _generation && revalidate()) action(); + }); + WidgetsBinding.instance.scheduleFrame(); + }); + } +} + +/// Thread-scoped typing status with optional size animation. +class _ThreadTypingIndicator extends StatelessWidget { + final List entries; + final bool animated; + + const _ThreadTypingIndicator({required this.entries, this.animated = true}); + + @override + Widget build(BuildContext context) { + final child = entries.isEmpty + ? const SizedBox.shrink() + : ChannelTypingIndicator(entries: entries); + if (!animated || MediaQuery.disableAnimationsOf(context)) return child; + return AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + alignment: Alignment.bottomCenter, + child: child, + ); + } +} diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 935e67e98ec..fecdea01fff 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -18,7 +18,6 @@ import 'channel_messages_provider.dart'; import 'channel_typing_provider.dart'; import 'channel_typing_indicator.dart'; import 'thread_replies_provider.dart'; -import 'thread_tail_intent.dart'; import 'channels_provider.dart'; import 'compose_bar.dart'; import 'composer_dock_size_reporter.dart'; @@ -37,6 +36,8 @@ import 'send_message_provider.dart'; import 'small_avatar.dart'; import 'timeline_message.dart'; +part 'thread_detail_helpers.dart'; + /// Full-screen thread detail page. /// /// Shows the thread head message, direct replies, typing indicators scoped to @@ -110,15 +111,13 @@ class ThreadDetailPage extends HookConsumerWidget { final didJumpToInitialMessage = useRef(false); final followsThreadTail = useRef(false); final userOptedOutOfTailFollow = useRef(false); - final tailIntent = useMemoized(ThreadTailIntent.new); + final tailIntent = useMemoized(_ThreadTailIntent.new); final pendingTailAlignment = useRef(null); const headIndex = 0; int indexForReply(int chronologicalIndex) => chronologicalIndex + 1; bool threadTailIsVisible() { - final lastIndex = replies.isEmpty - ? headIndex - : indexForReply(replies.length - 1); + final lastIndex = _threadTailIndex(replies.length); return itemPositionsListener.itemPositions.value.any( (position) => position.index == lastIndex && position.itemTrailingEdge <= 1.001, @@ -179,9 +178,11 @@ class ThreadDetailPage extends HookConsumerWidget { bool allowIdleDetached = false, bool animate = true, }) { - if (!currentIntentAllowsTailMutation( - allowIdleDetached: allowIdleDetached, - )) { + if (!initialTailSettle.isComplete || + viewportHeight <= 0 || + !currentIntentAllowsTailMutation( + allowIdleDetached: allowIdleDetached, + )) { return; } if (!allowIdleDetached) followsThreadTail.value = true; @@ -194,17 +195,19 @@ class ThreadDetailPage extends HookConsumerWidget { allowIdleDetached: allowIdleDetached, ), action: () { - final lastIndex = replies.isEmpty - ? headIndex - : indexForReply(replies.length - 1); + final lastIndex = _threadTailIndex(replies.length); if (animate) { itemScrollController.scrollTo( index: lastIndex, + alignment: topOverlayFraction, duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic, ); } else { - itemScrollController.jumpTo(index: lastIndex); + itemScrollController.jumpTo( + index: lastIndex, + alignment: topOverlayFraction, + ); } }, ); @@ -306,9 +309,7 @@ class ThreadDetailPage extends HookConsumerWidget { pendingTailAlignment.value = null; return; } - final lastIndex = replies.isEmpty - ? headIndex - : indexForReply(replies.length - 1); + final lastIndex = _threadTailIndex(replies.length); final lastPosition = itemPositionsListener.itemPositions.value .where((position) => position.index == lastIndex) .firstOrNull; @@ -516,7 +517,7 @@ class ThreadDetailPage extends HookConsumerWidget { ), ), if (!isMember || isArchived) - ThreadTypingIndicator(entries: threadTyping, animated: false), + _ThreadTypingIndicator(entries: threadTyping, animated: false), ], ), if (isMember && !isArchived) @@ -528,7 +529,7 @@ class ThreadDetailPage extends HookConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - ThreadTypingIndicator(entries: threadTyping), + _ThreadTypingIndicator(entries: threadTyping), ComposeBar( channelId: channelId, hintText: 'Reply in thread\u2026', @@ -706,15 +707,6 @@ class _NestedThreadSummaryRow extends ConsumerWidget { } } -class _ThreadTailMetricsObserver with WidgetsBindingObserver { - final VoidCallback onMetricsChanged; - - _ThreadTailMetricsObserver({required this.onMetricsChanged}); - - @override - void didChangeMetrics() => onMetricsChanged(); -} - class _ThreadMessage extends ConsumerWidget { final TimelineMessage message; final Map channelNames; diff --git a/mobile/lib/features/channels/thread_tail_intent.dart b/mobile/lib/features/channels/thread_tail_intent.dart deleted file mode 100644 index 825f0370cf6..00000000000 --- a/mobile/lib/features/channels/thread_tail_intent.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:flutter/widgets.dart'; - -/// Serializes deferred tail work behind the latest user scroll intent. -class ThreadTailIntent { - var _generation = 0; - var isDragging = false; - - void detach() => _generation++; - - void beginDrag() { - isDragging = true; - detach(); - } - - void endDrag() => isDragging = false; - - void schedule({ - required bool allowed, - required bool Function() revalidate, - required VoidCallback action, - }) { - if (!allowed) return; - final generation = ++_generation; - WidgetsBinding.instance.addPostFrameCallback((_) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (generation == _generation && revalidate()) action(); - }); - }); - } -} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 0eece48c777..47ef33d0ab5 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -4787,78 +4787,99 @@ void main() { (name: 'archived', isMember: true, isArchived: true), (name: 'writable member', isMember: true, isArchived: false), ]) { - testWidgets( - '${layout.name} typing transitions preserve a followed tail', - (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: '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'], - ], + for (final tail in <({String name, bool isLong})>[ + (name: 'short', isLong: false), + (name: 'long', isLong: true), + ]) { + testWidgets( + '${layout.name} typing transitions preserve a followed ${tail.name} tail', + (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: 'Root', + createdAt: 1000, + ); + final replies = [ + for (var i = 0; i < 30; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'bob', + content: i == 29 && tail.isLong + ? List.filled(8, 'Tall latest reply').join('\n') + : 'Reply $i', + createdAt: 1100 + i, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + ]; + final typingNotifier = _FakeTypingNotifier(const []); + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + typingNotifier: typingNotifier, + threadReplies: {'thread-root': replies}, + disableAnimations: true, ), - ]; - final typingNotifier = _FakeTypingNotifier(const []); - await tester.pumpWidget( - _buildTestable( - messages: [rootEvent], - typingNotifier: typingNotifier, - threadReplies: {'thread-root': replies}, - disableAnimations: true, - ), - ); - 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: layout.isMember, - isArchived: layout.isArchived, + ); + 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: layout.isMember, + isArchived: layout.isArchived, + ), ), - ), - ); - await tester.pumpAndSettle(); - final latest = find.byKey( - const ValueKey('thread-message-group-reply-29'), - ); - final list = find.byKey(const ValueKey('thread-message-list')); - final initialHeight = tester.getSize(list).height; - expect(latest, findsOneWidget); - typingNotifier.setEntries(const [ - TypingEntry( - pubkey: 'bob', - threadHeadId: 'thread-root', - expiresAtMs: 9999999999999, - ), - ]); - await tester.pumpAndSettle(); - expect(latest, findsOneWidget); - if (!layout.isMember || layout.isArchived) { - expect(tester.getSize(list).height, lessThan(initialHeight)); - } - typingNotifier.setEntries(const []); - await tester.pumpAndSettle(); - expect(latest, findsOneWidget); - expect(tester.getSize(list).height, closeTo(initialHeight, 0.5)); - }, - ); + ); + await tester.pumpAndSettle(); + final latest = find.byKey( + const ValueKey('thread-message-group-reply-29'), + ); + final list = find.byKey(const ValueKey('thread-message-list')); + final initialHeight = tester.getSize(list).height; + void expectTailWithinList() { + final latestRect = tester.getRect(latest); + final listRect = tester.getRect(list); + expect( + latestRect.top, + greaterThanOrEqualTo( + frostedAppBarHeight(tester.element(latest)), + ), + ); + if (tail.isLong) { + expect(latestRect.bottom, lessThanOrEqualTo(listRect.bottom)); + } + } + + expectTailWithinList(); + typingNotifier.setEntries(const [ + TypingEntry( + pubkey: 'bob', + threadHeadId: 'thread-root', + expiresAtMs: 9999999999999, + ), + ]); + await tester.pumpAndSettle(); + expectTailWithinList(); + if (!layout.isMember || layout.isArchived) { + expect(tester.getSize(list).height, lessThan(initialHeight)); + } + typingNotifier.setEntries(const []); + await tester.pumpAndSettle(); + expectTailWithinList(); + expect(tester.getSize(list).height, closeTo(initialHeight, 0.5)); + }, + ); + } testWidgets( '${layout.name} typing transitions preserve a detached anchor', From 378979cff2525741fb6e9f6472a6e7867d73b5f0 Mon Sep 17 00:00:00 2001 From: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Mon, 10 Aug 2026 19:09:23 -0400 Subject: [PATCH 17/20] fix(mobile): guard thread composer alignment Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../features/channels/thread_detail_page.dart | 6 +- .../channels/channel_detail_page_test.dart | 85 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index fecdea01fff..9732242486b 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -305,7 +305,11 @@ class ThreadDetailPage extends HookConsumerWidget { (followsThreadTail.value || threadTailIsVisible()); if (shouldFollowTail) followsThreadTail.value = true; composerDockHeight.value = height; - if (heightDelta <= 0 || !shouldFollowTail) { + if (heightDelta <= 0 || + !shouldFollowTail || + !viewportHeight.isFinite || + viewportHeight <= 0 || + !initialTailSettle.isComplete) { pendingTailAlignment.value = null; return; } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 47ef33d0ab5..23a3b741d76 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -14,6 +14,7 @@ import 'package:buzz/features/channels/channel_detail_page.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/channel_messages_provider.dart'; import 'package:buzz/features/channels/channel_typing_provider.dart'; +import 'package:buzz/features/channels/composer_dock_size_reporter.dart'; import 'package:buzz/features/channels/date_formatters.dart'; import 'package:buzz/features/channels/day_divider.dart'; import 'package:buzz/features/channels/emoji_picker.dart'; @@ -4087,6 +4088,90 @@ void main() { ); }); + for (final replyCount in [0, 1]) { + testWidgets( + 'cached writable $replyCount-reply thread defers dock correction until measured', + (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: 'Short root', + createdAt: 1000, + ); + final replies = [ + if (replyCount == 1) + _textMsg( + id: 'reply-0', + pubkey: 'bob', + content: 'Short reply', + createdAt: 1100, + 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 routeMessages = formatTimeline([rootEvent, ...replies]); + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: routeMessages.first, + allMessages: routeMessages, + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + ), + ); + + await tester.pump(); + expect(tester.takeException(), isNull); + final earlyDock = tester.widget( + find.byType(ComposerDockSizeReporter).last, + ); + + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + earlyDock.onHeightChanged(200); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + final composerSurface = find.byKey( + const ValueKey('composer-surface'), + ); + final tail = find.byKey( + ValueKey( + replyCount == 0 + ? 'thread-message-group-thread-root' + : 'thread-message-group-reply-0', + ), + ); + expect(tail, findsOneWidget); + expect( + tester.getTopLeft(tail).dy, + greaterThanOrEqualTo(frostedAppBarHeight(tester.element(tail))), + ); + expect( + tester.getBottomLeft(tail).dy, + lessThanOrEqualTo(tester.getTopLeft(composerSurface).dy), + ); + }, + ); + } + testWidgets( 'cached initial thread settles between the app bar and measured composer', (tester) async { From aaf9f4d9e88b767db56950bbb5c4f7b861c0971f Mon Sep 17 00:00:00 2001 From: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Mon, 10 Aug 2026 19:38:22 -0400 Subject: [PATCH 18/20] fix(mobile): respect thread composer visibility Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../channels/thread_detail_helpers.dart | 61 +++ .../features/channels/thread_detail_page.dart | 60 +-- .../channels/channel_detail_page_test.dart | 353 ++++++++++++++++++ 3 files changed, 434 insertions(+), 40 deletions(-) diff --git a/mobile/lib/features/channels/thread_detail_helpers.dart b/mobile/lib/features/channels/thread_detail_helpers.dart index bb916f409fe..e4d0f76ff3d 100644 --- a/mobile/lib/features/channels/thread_detail_helpers.dart +++ b/mobile/lib/features/channels/thread_detail_helpers.dart @@ -2,6 +2,67 @@ part of 'thread_detail_page.dart'; int _threadTailIndex(int replyCount) => replyCount; +double _threadTailTrailingBoundary({ + required bool hasComposerDock, + required double viewportHeight, + required double dockHeight, +}) { + if (!hasComposerDock) return 1.001; + if (!viewportHeight.isFinite || + viewportHeight <= 0 || + !dockHeight.isFinite || + dockHeight <= 0) { + return double.negativeInfinity; + } + return 1 - (dockHeight / viewportHeight) + 0.001; +} + +void _resumeThreadTailFollow({ + required bool Function() isVisible, + required ObjectRef userOptedOut, + required ObjectRef followsTail, +}) { + if (!isVisible()) return; + userOptedOut.value = false; + followsTail.value = true; +} + +bool _isDeletedBy(Iterable events, String messageId) { + for (final event in events) { + if (event.kind != EventKind.deletion && + event.kind != EventKind.nip29DeleteEvent) { + continue; + } + if (event.tags.any( + (tag) => tag.length >= 2 && tag[0] == 'e' && tag[1] == messageId, + )) { + return true; + } + } + return false; +} + +/// Build a lightweight summary for a nested thread (reply that has its own +/// replies). Same logic as the top-level [ThreadSummary] but kept local to +/// avoid coupling. +ThreadSummary _buildNestedSummary( + String messageId, + List children, +) { + final seen = {}; + final participants = []; + for (var i = children.length - 1; i >= 0 && participants.length < 3; i--) { + final pk = children[i].pubkey.toLowerCase(); + if (seen.add(pk)) participants.add(pk); + } + return ThreadSummary( + threadHeadId: messageId, + replyCount: children.length, + participantPubkeys: participants.reversed.toList(), + lastReplyAt: children.last.createdAt, + ); +} + class _ThreadTailMetricsObserver with WidgetsBindingObserver { final VoidCallback onMetricsChanged; diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 9732242486b..bfb61e56fbc 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -118,9 +118,15 @@ class ThreadDetailPage extends HookConsumerWidget { bool threadTailIsVisible() { final lastIndex = _threadTailIndex(replies.length); + final trailingBoundary = _threadTailTrailingBoundary( + hasComposerDock: isMember && !isArchived, + viewportHeight: listViewport.height.value, + dockHeight: composerDockHeight.value, + ); return itemPositionsListener.itemPositions.value.any( (position) => - position.index == lastIndex && position.itemTrailingEdge <= 1.001, + position.index == lastIndex && + position.itemTrailingEdge <= trailingBoundary, ); } @@ -380,9 +386,19 @@ class ThreadDetailPage extends HookConsumerWidget { }, onUserScrollEnd: () { tailIntent.endDrag(); - if (!threadTailIsVisible()) return; - userOptedOutOfTailFollow.value = false; - followsThreadTail.value = true; + tailIntent.schedule( + allowed: userOptedOutOfTailFollow.value, + revalidate: () => + context.mounted && + itemScrollController.isAttached && + !tailIntent.isDragging && + userOptedOutOfTailFollow.value, + action: () => _resumeThreadTailFollow( + isVisible: threadTailIsVisible, + userOptedOut: userOptedOutOfTailFollow, + followsTail: followsThreadTail, + ), + ); }, child: ScrollablePositionedList.builder( key: const ValueKey('thread-message-list'), @@ -563,42 +579,6 @@ class ThreadDetailPage extends HookConsumerWidget { } } -bool _isDeletedBy(Iterable events, String messageId) { - for (final event in events) { - if (event.kind != EventKind.deletion && - event.kind != EventKind.nip29DeleteEvent) { - continue; - } - if (event.tags.any( - (tag) => tag.length >= 2 && tag[0] == 'e' && tag[1] == messageId, - )) { - return true; - } - } - return false; -} - -/// Build a lightweight summary for a nested thread (reply that has its own -/// replies). Same logic as the top-level [ThreadSummary] but kept local to -/// avoid coupling. -ThreadSummary _buildNestedSummary( - String messageId, - List children, -) { - final seen = {}; - final participants = []; - for (var i = children.length - 1; i >= 0 && participants.length < 3; i--) { - final pk = children[i].pubkey.toLowerCase(); - if (seen.add(pk)) participants.add(pk); - } - return ThreadSummary( - threadHeadId: messageId, - replyCount: children.length, - participantPubkeys: participants.reversed.toList(), - lastReplyAt: children.last.createdAt, - ); -} - /// Tappable summary row shown below a reply that itself has replies. /// Pushes a new [ThreadDetailPage] for the nested thread. class _NestedThreadSummaryRow extends ConsumerWidget { diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 23a3b741d76..82ca315dece 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -5046,6 +5046,359 @@ void main() { ); } + for (final layout in [ + (name: 'non-member', isMember: false, isArchived: false), + (name: 'archived', isMember: true, isArchived: true), + ]) { + testWidgets('${layout.name} no-dock tail resumes full-viewport follow', ( + tester, + ) async { + 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'], + ], + ), + ]; + final messagesNotifier = _FakeMessagesNotifier([rootEvent]); + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + messagesNotifier: messagesNotifier, + threadReplies: {'thread-root': replies}, + ), + ); + 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: layout.isMember, + isArchived: layout.isArchived, + ), + ), + ); + await tester.pumpAndSettle(); + + final lifecycle = tester.widget( + find.byType(KeyboardDismissOnDrag), + ); + lifecycle.onUserScrollStart!(); + lifecycle.onUserScrollEnd!(); + await tester.pumpAndSettle(); + messagesNotifier.setMessages([ + rootEvent, + _textMsg( + id: 'reply-remote', + pubkey: 'bob', + content: 'Remote tail', + createdAt: 9999, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + ]); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('thread-message-group-reply-remote')), + findsOneWidget, + ); + }); + } + + testWidgets( + 'composer-covered tail remains detached after drag-end settlement', + (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'], + ], + ), + ]; + final messagesNotifier = _FakeMessagesNotifier([rootEvent]); + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + messagesNotifier: messagesNotifier, + threadReplies: {'thread-root': replies}, + ), + ); + 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')); + final latest = find.byKey( + const ValueKey('thread-message-group-reply-29'), + ); + final composer = find.byKey(const ValueKey('composer-surface')); + await tester.drag(list, const Offset(0, 24)); + await tester.pumpAndSettle(); + expect( + tester.getBottomLeft(latest).dy, + greaterThan(tester.getTopLeft(composer).dy), + ); + expect( + tester.getBottomLeft(latest).dy, + lessThanOrEqualTo(tester.getBottomLeft(list).dy), + ); + final visibleBeforeRemote = tester + .widgetList( + find.byWidgetPredicate( + (widget) => + widget.key is ValueKey && + (widget.key! as ValueKey).value.startsWith( + 'thread-message-group-', + ), + ), + ) + .map((widget) => widget.key) + .toSet(); + final anchorKey = visibleBeforeRemote.firstWhere( + (key) => key != latest.evaluate().single.widget.key, + ); + final anchor = find.byKey(anchorKey!); + final detachedTop = tester.getTopLeft(anchor).dy; + + messagesNotifier.setMessages([ + rootEvent, + _textMsg( + id: 'reply-remote', + pubkey: 'bob', + content: 'Remote tail', + createdAt: 9999, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + ]); + await tester.pumpAndSettle(); + expect( + tester + .widgetList( + find.byWidgetPredicate( + (widget) => visibleBeforeRemote.contains(widget.key), + ), + ) + .map((widget) => widget.key), + isNotEmpty, + ); + expect(tester.getTopLeft(anchor).dy, closeTo(detachedTop, 0.5)); + + await tester.tap(find.text('Reply in thread…').hitTestable()); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(anchor).dy, closeTo(detachedTop, 0.5)); + + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(anchor).dy, closeTo(detachedTop, 0.5)); + }, + ); + + testWidgets('invalid writable dock geometry cannot resume tail follow', ( + tester, + ) async { + 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'], + ], + ), + ]; + final messagesNotifier = _FakeMessagesNotifier([rootEvent]); + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + messagesNotifier: messagesNotifier, + threadReplies: {'thread-root': replies}, + ), + ); + 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(); + + tester + .widget( + find.byType(ComposerDockSizeReporter).last, + ) + .onHeightChanged(0); + await tester.pumpAndSettle(); + final lifecycle = tester.widget( + find.byType(KeyboardDismissOnDrag), + ); + lifecycle.onUserScrollStart!(); + lifecycle.onUserScrollEnd!(); + await tester.pumpAndSettle(); + final anchor = find.byKey( + const ValueKey('thread-message-group-reply-29'), + ); + final anchorTop = tester.getTopLeft(anchor).dy; + + messagesNotifier.setMessages([ + rootEvent, + _textMsg( + id: 'reply-remote', + pubkey: 'bob', + content: 'Remote tail', + createdAt: 9999, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + ]); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(anchor).dy, closeTo(anchorTop, 0.5)); + }); + + testWidgets('new drag invalidates deferred drag-end resumption', ( + tester, + ) async { + 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'], + ], + ), + ]; + final messagesNotifier = _FakeMessagesNotifier([rootEvent]); + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + messagesNotifier: messagesNotifier, + threadReplies: {'thread-root': replies}, + ), + ); + 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(); + + var lifecycle = tester.widget( + find.byType(KeyboardDismissOnDrag), + ); + lifecycle.onUserScrollStart!(); + lifecycle.onUserScrollEnd!(); + await tester.pump(); + lifecycle = tester.widget( + find.byType(KeyboardDismissOnDrag), + ); + lifecycle.onUserScrollStart!(); + await tester.pumpAndSettle(); + final anchor = find.byKey( + const ValueKey('thread-message-group-reply-29'), + ); + final anchorTop = tester.getTopLeft(anchor).dy; + + messagesNotifier.setMessages([ + rootEvent, + _textMsg( + id: 'reply-remote', + pubkey: 'bob', + content: 'Remote tail', + createdAt: 9999, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + ]); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(anchor).dy, closeTo(anchorTop, 0.5)); + tester + .widget(find.byType(KeyboardDismissOnDrag)) + .onUserScrollEnd!(); + }); + testWidgets( 'dragging away opts out, then returning to the tail resumes keyboard realignment', (tester) async { From 5ddc3b7bf8dc8c5fa1fcc2c2cbeea107cdac1662 Mon Sep 17 00:00:00 2001 From: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Wed, 12 Aug 2026 13:28:06 -0400 Subject: [PATCH 19/20] fix(mobile): preserve thread tail intent transitions Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../features/channels/thread_detail_page.dart | 32 +++++-- .../channels/channel_detail_page_test.dart | 92 +++++++++++++++++++ 2 files changed, 115 insertions(+), 9 deletions(-) diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index bfb61e56fbc..89662883d2d 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -155,14 +155,20 @@ class ThreadDetailPage extends HookConsumerWidget { ? null : indexForReply(chronologicalIndex); if (targetIndex == null || didJumpToInitialMessage.value) return null; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!context.mounted || !itemScrollController.isAttached) return; - tailIntent.detach(); - followsThreadTail.value = false; - pendingTailAlignment.value = null; - itemScrollController.jumpTo(index: targetIndex, alignment: 0.35); - didJumpToInitialMessage.value = true; - }); + didJumpToInitialMessage.value = true; + tailIntent.schedule( + allowed: true, + revalidate: () => + context.mounted && + itemScrollController.isAttached && + !tailIntent.isDragging, + action: () { + tailIntent.detach(); + followsThreadTail.value = false; + pendingTailAlignment.value = null; + itemScrollController.jumpTo(index: targetIndex, alignment: 0.35); + }, + ); return null; }, [initialMessageId, fetchedReplies, replies.length]); @@ -182,6 +188,7 @@ class ThreadDetailPage extends HookConsumerWidget { void queueTailRealignment({ bool allowIdleDetached = false, + bool restoreFollow = false, bool animate = true, }) { if (!initialTailSettle.isComplete || @@ -202,6 +209,10 @@ class ThreadDetailPage extends HookConsumerWidget { ), action: () { final lastIndex = _threadTailIndex(replies.length); + if (restoreFollow) { + userOptedOutOfTailFollow.value = false; + followsThreadTail.value = true; + } if (animate) { itemScrollController.scrollTo( index: lastIndex, @@ -265,7 +276,10 @@ class ThreadDetailPage extends HookConsumerWidget { if (!hasNewLocalReply && (userOptedOutOfTailFollow.value || !wasAtTail)) { return null; } - queueTailRealignment(allowIdleDetached: hasNewLocalReply); + queueTailRealignment( + allowIdleDetached: hasNewLocalReply, + restoreFollow: hasNewLocalReply, + ); return null; }, [hasFetchedReplies, replies.length, settleGeometry]); final readState = ref.watch(readStateProvider); diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 82ca315dece..a77fd846823 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -4397,6 +4397,75 @@ void main() { }, ); + testWidgets('active drag cancels a queued hydrated deep-link jump', ( + tester, + ) async { + final rootEvent = _textMsg( + id: 'thread-root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final replies = [ + for (var i = 0; i < 35; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'bob', + content: 'Reply $i', + createdAt: 1100 + i, + 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 provisional = formatTimeline([rootEvent, ...replies.take(30)]); + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: provisional.first, + allMessages: provisional, + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + initialMessageId: 'reply-5', + ), + ), + ); + await tester.pumpAndSettle(); + + final anchor = find.byKey( + const ValueKey('thread-message-group-thread-root'), + ); + final anchorTop = tester.getTopLeft(anchor).dy; + completer.complete(replies); + await tester.pump(); + + // Authoritative hydration has queued the deep-link jump. A primary drag + // takes ownership before the shared deferred-intent boundary executes it. + tester + .widget(find.byType(KeyboardDismissOnDrag)) + .onUserScrollStart!(); + await tester.pumpAndSettle(); + + expect(anchor, findsOneWidget); + expect(tester.getTopLeft(anchor).dy, closeTo(anchorTop, 0.5)); + }); + testWidgets('user drag invalidates an already queued hydration settle', ( tester, ) async { @@ -4865,6 +4934,29 @@ void main() { find.byKey(const ValueKey('thread-message-group-reply-local')), findsOneWidget, ); + + final laterRemoteReplies = [ + for (var i = 0; i < 10; i++) + _textMsg( + id: 'reply-after-local-$i', + pubkey: 'bob', + content: List.filled(8, 'Tall remote reply $i').join('\n'), + createdAt: 1300 + i, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + ]; + messagesNotifier.setMessages([ + rootEvent, + localReply, + ...laterRemoteReplies, + ]); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('thread-message-group-reply-after-local-9')), + findsOneWidget, + ); }); for (final layout in <({String name, bool isMember, bool isArchived})>[ From cdc7312548b113c551f24bcd27e2f64cf2ba5302 Mon Sep 17 00:00:00 2001 From: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Wed, 12 Aug 2026 15:06:17 -0400 Subject: [PATCH 20/20] fix(mobile): preserve thread settle scheduling Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../channels/initial_thread_tail_settle.dart | 4 + .../features/channels/thread_detail_page.dart | 7 +- .../channels/channel_detail_page_test.dart | 148 +++++++++++++++++- 3 files changed, 155 insertions(+), 4 deletions(-) diff --git a/mobile/lib/features/channels/initial_thread_tail_settle.dart b/mobile/lib/features/channels/initial_thread_tail_settle.dart index 3e6fa271ba8..2e41d18e5b0 100644 --- a/mobile/lib/features/channels/initial_thread_tail_settle.dart +++ b/mobile/lib/features/channels/initial_thread_tail_settle.dart @@ -79,6 +79,10 @@ class InitialThreadTailSettle { if (generation == _generation) _isComplete = true; }); }); + // A post-frame callback does not itself request the frame in which it + // runs. Slow hydration can otherwise leave this settle parked until an + // unrelated redraw. + WidgetsBinding.instance.scheduleFrame(); }); } } diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 89662883d2d..9de8dc861f6 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -256,7 +256,12 @@ class ThreadDetailPage extends HookConsumerWidget { (viewportHeight - previousViewportHeight.value).abs() >= 0.5; previousViewportHeight.value = viewportHeight; if (replies.length <= previous) { - if (viewportChanged) queueTailRealignment(animate: false); + // Preserve a short thread's valid top anchor when resize leaves its + // tail inside the newly measured usable viewport. Long/clipped tails + // still follow through the shared intent-serialized correction path. + if (viewportChanged && !threadTailIsVisible()) { + queueTailRealignment(animate: false); + } return null; } final positions = itemPositionsListener.itemPositions.value; diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index a77fd846823..fbb12f71771 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -4697,6 +4697,72 @@ void main() { expect(tester.getTopLeft(headFinder).dy, closeTo(initialHeadY, 0.5)); }); + testWidgets( + 'slow initial hydration requests the frame that settles the latest reply', + (tester) async { + 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'], + ], + ), + ]; + 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(); + + completer.complete(replies); + await tester.pump(); + expect(tester.binding.hasScheduledFrame, isTrue); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('thread-message-group-thread-root')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('thread-message-group-reply-29')), + findsOneWidget, + ); + }, + ); + testWidgets( 'initial thread hydration settles on the latest reply after pagination', (tester) async { @@ -4959,6 +5025,69 @@ void main() { ); }); + testWidgets( + 'short followed thread stays top-anchored after viewport resize', + (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: 'Root', + createdAt: 1000, + ); + final replies = [ + for (var i = 0; i < 3; 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}, + ), + ); + 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: false, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + + final head = find.byKey( + const ValueKey('thread-message-group-thread-root'), + ); + final latest = find.byKey( + const ValueKey('thread-message-group-reply-2'), + ); + final initialHeadTop = tester.getTopLeft(head).dy; + + tester.view.viewInsets = const FakeViewPadding(bottom: 100); + await tester.pumpAndSettle(); + + expect(latest, findsOneWidget); + expect(tester.getTopLeft(head).dy, closeTo(initialHeadTop, 0.5)); + }, + ); + for (final layout in <({String name, bool isMember, bool isArchived})>[ (name: 'non-member', isMember: false, isArchived: false), (name: 'archived', isMember: true, isArchived: true), @@ -4980,12 +5109,13 @@ void main() { content: 'Root', createdAt: 1000, ); + final replyCount = tail.isLong ? 30 : 6; final replies = [ - for (var i = 0; i < 30; i++) + for (var i = 0; i < replyCount; i++) _textMsg( id: 'reply-$i', pubkey: 'bob', - content: i == 29 && tail.isLong + content: i == replyCount - 1 && tail.isLong ? List.filled(8, 'Tall latest reply').join('\n') : 'Reply $i', createdAt: 1100 + i, @@ -5019,8 +5149,14 @@ void main() { ); await tester.pumpAndSettle(); final latest = find.byKey( - const ValueKey('thread-message-group-reply-29'), + ValueKey('thread-message-group-reply-${replyCount - 1}'), ); + final head = find.byKey( + const ValueKey('thread-message-group-thread-root'), + ); + final initialHeadTop = tail.isLong + ? null + : tester.getTopLeft(head).dy; final list = find.byKey(const ValueKey('thread-message-list')); final initialHeight = tester.getSize(list).height; void expectTailWithinList() { @@ -5047,12 +5183,18 @@ void main() { ]); await tester.pumpAndSettle(); expectTailWithinList(); + if (initialHeadTop != null) { + expect(tester.getTopLeft(head).dy, closeTo(initialHeadTop, 0.5)); + } if (!layout.isMember || layout.isArchived) { expect(tester.getSize(list).height, lessThan(initialHeight)); } typingNotifier.setEntries(const []); await tester.pumpAndSettle(); expectTailWithinList(); + if (initialHeadTop != null) { + expect(tester.getTopLeft(head).dy, closeTo(initialHeadTop, 0.5)); + } expect(tester.getSize(list).height, closeTo(initialHeight, 0.5)); }, );