diff --git a/mobile/lib/features/channels/thread_detail_helpers.dart b/mobile/lib/features/channels/thread_detail_helpers.dart index fc450bd95e..0baa96f41e 100644 --- a/mobile/lib/features/channels/thread_detail_helpers.dart +++ b/mobile/lib/features/channels/thread_detail_helpers.dart @@ -16,6 +16,35 @@ Widget _trackActiveThreadScrollPosition( }, ); +/// Keeps a tail-following thread at its end while content there grows. +/// +/// A sent reply's entrance transition grows its row over several frames, and +/// media or typing rows can grow too. The one-shot tail correction can finish +/// before that growth does, leaving the new content under the composer. Every +/// growth reports new scroll metrics, so move a position that was at its old +/// end to the new one while the reader follows the tail. +void _followThreadTailOnMetricsChange( + ScrollMetricsNotification notification, { + required ScrollPosition? activePosition, + required Expando previousMaxExtents, + required bool Function() followsTail, +}) { + final position = Scrollable.maybeOf(notification.context)?.position; + if (position == null || + !identical(position, activePosition) || + !position.hasContentDimensions) { + return; + } + final maxExtent = position.maxScrollExtent; + final previousMaxExtent = previousMaxExtents[position]; + previousMaxExtents[position] = maxExtent; + if (previousMaxExtent == null || maxExtent <= previousMaxExtent) return; + final wasAtEnd = + position.pixels >= previousMaxExtent - _threadTailScrollTolerance; + if (!wasAtEnd || !followsTail()) return; + position.jumpTo(maxExtent); +} + bool _jumpActiveThreadScrollToTail( ObjectRef activePosition, bool Function()? testOverride, diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 87945397cc..9063cc6de6 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -291,6 +291,7 @@ class ThreadDetailPage extends HookConsumerWidget { final tailCorrectionInProgress = useRef(false); final tailCorrectionGeneration = useRef(0); final activeThreadScrollPosition = useRef(null); + final threadMaxScrollExtents = useMemoized(Expando.new); final composerHasFocus = useListenable(composerFocusNode).hasFocus; final viewportHeight = useListenable(listViewport.height).value; final previousViewportHeight = useRef(viewportHeight); @@ -645,7 +646,12 @@ class ThreadDetailPage extends HookConsumerWidget { headHeight.value == null) { return null; } - if (initialMessageId != null) { + // A targeted entry starts at its target, not the tail. Absorb the + // replies it hydrates with (a provisional list, then the relay's) until + // the target jump reveals the list; a tail correction scheduled earlier + // would cancel that one-shot jump and leave the list hidden. Later + // arrivals use the ordinary rules below, so a sent reply comes into view. + if (initialMessageId != null && !initialViewportReady.value) { initialTailSettle.abandon(); previousReplyCount.value = replies.length; return null; @@ -953,76 +959,102 @@ class ThreadDetailPage extends HookConsumerWidget { Column( children: [ Expanded( - child: _ThreadMessageList( - dateScrollActivity: dateScrollActivity, - headHeight: headHeight.value ?? 0, - onHeadHeightChanged: (height) { - if (context.mounted) headHeight.value = height; + child: NotificationListener( + onNotification: (notification) { + if (notification.depth == 0) { + _followThreadTailOnMetricsChange( + notification, + activePosition: activeThreadScrollPosition.value, + previousMaxExtents: threadMaxScrollExtents, + followsTail: () => + followsThreadTail.value && + initialTailSettle.isComplete && + !tailIntent.isDragging && + !userOptedOutOfTailFollow.value && + !isNavigatingToThreadTail.value && + // A jump away from the tail can briefly leave the + // position at an estimated end; only content + // growing at the real tail should be followed. + itemPositionsListener.itemPositions.value.any( + (item) => + item.index == + _threadTailIndex(replies.length), + ), + ); + } + return false; }, - viewport: listViewport, - onUserScrollStart: () { - hidesLatestForInitialTailSettle.value = false; - hidesLatestForComposerTailCorrection.value = false; - initialTailSettle.abandon(); - initialViewportReady.value = true; - tailCorrectionInProgress.value = false; - isNavigatingToThreadTail.value = false; - tailIntent.beginDrag(); - userOptedOutOfTailFollow.value = true; - userDragDetachedTailFollow.value = true; - followsThreadTail.value = false; - }, - onUserScrollEnd: () { - tailIntent.endDrag(); - tailIntent.schedule( - allowed: userOptedOutOfTailFollow.value, - revalidate: () => - context.mounted && - itemScrollController.isAttached && - !tailIntent.isDragging && - userOptedOutOfTailFollow.value, - action: () { - _resumeThreadTailFollow( - isVisible: threadTailIsVisible, - userOptedOut: userOptedOutOfTailFollow, - followsTail: followsThreadTail, - ); - if (!userOptedOutOfTailFollow.value) { - userDragDetachedTailFollow.value = false; - } - }, - ); - }, - visible: threadViewportVisible, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener, - bottomInset: timelineBottomInset, - replies: replies, - knownAgents: threadTyping.isEmpty - ? const {} - : ref.watch(meAgentInventoryProvider).value ?? const {}, - inlineTyping: isMember && !isArchived - ? threadTyping - : const [], - relayReplyState: relayReplyState, - onRetryReplies: () => - ref.invalidate(threadRepliesProvider(repliesArgs)), - localSendAnimations: localSendAnimations, - trackActiveScrollPosition: trackActiveScrollPosition, - headIsDeleted: liveDeletionHidesHead, - head: liveHead, - stickyDayTimestamp: stickyDayTimestamp, - channelNames: channelNamesMap, - channelId: channelId, - currentPubkey: currentPubkey, - highlightedMessageId: highlightedMessageId.value, - allMessages: allMsgs, - isMember: isMember, - isArchived: isArchived, - composerFocusNode: composerFocusNode, - restoreComposerFocus: () => - restoreComposerFocus.value?.call(), - childrenByParent: childrenByParent, + child: _ThreadMessageList( + dateScrollActivity: dateScrollActivity, + headHeight: headHeight.value ?? 0, + onHeadHeightChanged: (height) { + if (context.mounted) headHeight.value = height; + }, + viewport: listViewport, + onUserScrollStart: () { + hidesLatestForInitialTailSettle.value = false; + hidesLatestForComposerTailCorrection.value = false; + initialTailSettle.abandon(); + initialViewportReady.value = true; + tailCorrectionInProgress.value = false; + isNavigatingToThreadTail.value = false; + tailIntent.beginDrag(); + userOptedOutOfTailFollow.value = true; + userDragDetachedTailFollow.value = true; + followsThreadTail.value = false; + }, + onUserScrollEnd: () { + tailIntent.endDrag(); + tailIntent.schedule( + allowed: userOptedOutOfTailFollow.value, + revalidate: () => + context.mounted && + itemScrollController.isAttached && + !tailIntent.isDragging && + userOptedOutOfTailFollow.value, + action: () { + _resumeThreadTailFollow( + isVisible: threadTailIsVisible, + userOptedOut: userOptedOutOfTailFollow, + followsTail: followsThreadTail, + ); + if (!userOptedOutOfTailFollow.value) { + userDragDetachedTailFollow.value = false; + } + }, + ); + }, + visible: threadViewportVisible, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + bottomInset: timelineBottomInset, + replies: replies, + knownAgents: threadTyping.isEmpty + ? const {} + : ref.watch(meAgentInventoryProvider).value ?? const {}, + inlineTyping: isMember && !isArchived + ? threadTyping + : const [], + relayReplyState: relayReplyState, + onRetryReplies: () => + ref.invalidate(threadRepliesProvider(repliesArgs)), + localSendAnimations: localSendAnimations, + trackActiveScrollPosition: trackActiveScrollPosition, + headIsDeleted: liveDeletionHidesHead, + head: liveHead, + stickyDayTimestamp: stickyDayTimestamp, + channelNames: channelNamesMap, + channelId: channelId, + currentPubkey: currentPubkey, + highlightedMessageId: highlightedMessageId.value, + allMessages: allMsgs, + isMember: isMember, + isArchived: isArchived, + composerFocusNode: composerFocusNode, + restoreComposerFocus: () => + restoreComposerFocus.value?.call(), + childrenByParent: childrenByParent, + ), ), ), ], diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index df524ed213..03ec0e5008 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -17861,6 +17861,160 @@ void main() { }, ); + // A targeted entry can also hydrate in two steps: the route's snapshot + // of the target, then the relay's full thread, which may include the + // reader's own earlier replies. + for (final (initialMessageId, delayedHydration) in [ + (null, false), + ('reply-5', false), + ('reply-5', true), + ]) { + testWidgets('a sent reply stays above the composer as it grows in ' + '(target: $initialMessageId, delayed: $delayedHydration)', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown( + () => debugDefaultTargetPlatformOverride = previousPlatform, + ); + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + tester.view.viewPadding = const FakeViewPadding(bottom: 20); + addTearDown(tester.view.reset); + + final rootEvent = _textMsg( + id: threadIdThreadDashRoot, + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final replies = [ + for (var i = 0; i < 30; i++) + _textMsg( + id: 'reply-$i', + pubkey: delayedHydration && i.isEven ? 'self' : 'bob', + content: 'Reply $i', + createdAt: 1100 + i, + extraTags: const [ + ['e', threadIdThreadDashRoot, '', 'reply'], + ], + ), + ]; + + final hydration = Completer>(); + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + threadReplies: delayedHydration + ? const {} + : {threadIdThreadDashRoot: replies}, + threadReplyLoaders: delayedHydration + ? {threadIdThreadDashRoot: () => hydration.future} + : const {}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + final threadHead = formatTimeline([rootEvent]).single; + final target = formatTimeline([ + rootEvent, + replies[5], + ]).where((message) => message.id == 'reply-5'); + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: [threadHead, if (delayedHydration) ...target], + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + // Inbox, push and link entries open at an older target and must + // still bring the reader's own reply into view. + initialMessageId: initialMessageId, + ), + ), + ); + await tester.pumpAndSettle(); + if (delayedHydration) { + hydration.complete(replies); + await tester.pumpAndSettle(); + } + if (initialMessageId != null) { + expect( + find.text('Reply 5').hitTestable(), + findsOneWidget, + reason: 'The target jump must reveal the hydrated list.', + ); + } + // Readers scroll a little before replying, which detaches tail follow + // so focusing the composer no longer snaps to the tail by itself. + await tester.drag( + find.byType(ScrollablePositionedList), + const Offset(0, 80), + ); + await tester.pumpAndSettle(); + if (initialMessageId != null) { + expect(find.text('Reply 5'), findsOneWidget); + } + await tester.tap(find.text('Reply in thread…').hitTestable()); + await tester.pumpAndSettle(); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pumpAndSettle(); + + // Production adds a signed reply this way before the relay answers, and + // its row then grows to full height over the entrance transition. + final container = ProviderScope.containerOf( + tester.element(find.byType(ThreadDetailPage)), + ); + final sent = _textMsg( + id: 'sent-reply', + pubkey: 'self', + content: 'A sent reply\nwith a second line\nand a third line', + createdAt: 1200, + extraTags: const [ + ['e', threadIdThreadDashRoot, '', 'reply'], + ], + ); + container + .read(localMessageSendAnimationProvider(_channelId).notifier) + .mark(sent.id); + container + .read( + threadLocalRepliesProvider( + const ThreadRepliesArgs( + channelId: _channelId, + rootId: threadIdThreadDashRoot, + ), + ).notifier, + ) + .add(sent); + await tester.pumpAndSettle(); + debugDefaultTargetPlatformOverride = previousPlatform; + + final sentReply = find.byKey( + const ValueKey('thread-message-group-sent-reply'), + ); + final composerTop = tester + .getTopLeft(find.byKey(const ValueKey('composer-surface'))) + .dy; + expect( + tester.getBottomLeft(sentReply).dy, + lessThanOrEqualTo(composerTop), + reason: 'The fully grown reply must not end under the composer.', + ); + expect( + find.byKey(const ValueKey('thread-jump-to-latest')), + findsNothing, + ); + }); + } + testWidgets('thread hides initial tail placement until it is settled', ( tester, ) async {