From 7634fe74563ea7f3c86fb6017a0ad647a9934477 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 12 Aug 2026 17:25:32 -0400 Subject: [PATCH] fix(mobile): settle hydrated threads on latest reply (#4702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary Mobile threads could open above the newest reply because the reply query hydrates across relay pages while the list is still being laid out. Ordinary thread opens now wait for authoritative hydration and late layout before settling on the latest reply. The initial settle is generation-guarded: if another reply arrives while it is pending, the stale target is discarded and the current tail becomes the target. Explicit deep links still own their requested position, existing threads only follow remote replies when the previous tail was visible, and local sends remain visible. ### Related issue No matching issue found. This is separate from the channel unread-navigation behavior in #4239. Originating Buzz thread: `buzz://message?channel=a9081ecd-9be0-400b-8bf9-2e8e0d385b80&id=bfb289fc53754f62f641fbf58bf2d7a9c181a3e6eb09a6ba762aeb6904b6cde4&thread=bfb289fc53754f62f641fbf58bf2d7a9c181a3e6eb09a6ba762aeb6904b6cde4` ### Testing - Added a widget regression covering paginated hydration plus a live reply arriving during the initial settle. - Full mobile Flutter test suite passed; `flutter analyze` passed. - GitHub CI passed, including the Mobile job. - Built, installed, and launched the debug app on an iPad Pro 11-inch (M4), iOS 18.6 simulator. An authenticated manual thread traversal was not performed because the fresh app was not paired to a relay account. --------- Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: loganj Signed-off-by: npub1em3jmyn4vu57urqf03txrwreccvejvwdy5c4er8nnrwt7rc4tncscs3ssu Signed-off-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: npub1em3jmyn4vu57urqf03txrwreccvejvwdy5c4er8nnrwt7rc4tncscs3ssu Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: Codex Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> --- .../channels/initial_thread_tail_settle.dart | 88 + .../features/channels/laid_out_viewport.dart | 70 + .../channels/thread_detail_helpers.dart | 125 ++ .../features/channels/thread_detail_page.dart | 619 ++++--- .../widgets/keyboard_dismiss_on_drag.dart | 16 +- .../channels/channel_detail_page_test.dart | 1607 ++++++++++++++++- .../keyboard_dismiss_on_drag_test.dart | 170 +- 7 files changed, 2375 insertions(+), 320 deletions(-) create mode 100644 mobile/lib/features/channels/initial_thread_tail_settle.dart create mode 100644 mobile/lib/features/channels/laid_out_viewport.dart create mode 100644 mobile/lib/features/channels/thread_detail_helpers.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 000000000..2e41d18e5 --- /dev/null +++ b/mobile/lib/features/channels/initial_thread_tail_settle.dart @@ -0,0 +1,88 @@ +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; + + /// 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; + + /// 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 + /// arriving. The final target is left in place when already visible; otherwise + /// 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; + + 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; + } + final targetIsFullyVisible = positionsListener.itemPositions.value.any( + (position) => + position.index == targetIndex && + position.itemLeadingEdge >= hiddenTopFraction && + 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 + // head. A clipped tail still takes the measured correction path. + if (targetIsFullyVisible) { + _isComplete = true; + return; + } + controller + .scrollTo( + index: targetIndex, + alignment: hiddenTopFraction, + duration: const Duration(milliseconds: 1), + ) + .whenComplete(() { + 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/laid_out_viewport.dart b/mobile/lib/features/channels/laid_out_viewport.dart new file mode 100644 index 000000000..12fa4a2f8 --- /dev/null +++ b/mobile/lib/features/channels/laid_out_viewport.dart @@ -0,0 +1,70 @@ +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(); + if (renderObject is! RenderBox || !renderObject.hasSize) return; + final nextHeight = renderObject.size.height; + if ((height.value - nextHeight).abs() < 0.5) return; + height.value = nextHeight; + }); + } + + /// 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, + 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_helpers.dart b/mobile/lib/features/channels/thread_detail_helpers.dart new file mode 100644 index 000000000..e4d0f76ff --- /dev/null +++ b/mobile/lib/features/channels/thread_detail_helpers.dart @@ -0,0 +1,125 @@ +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; + + _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 2ce862ea7..9de8dc861 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -24,6 +24,8 @@ 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 'laid_out_viewport.dart'; import 'message_actions.dart'; import 'message_long_press_region.dart'; import 'message_content.dart'; @@ -34,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 @@ -62,20 +66,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 []; @@ -94,18 +90,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; @@ -116,28 +106,35 @@ 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); + final tailIntent = useMemoized(_ThreadTailIntent.new); 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; bool threadTailIsVisible() { - final lastIndex = replies.isEmpty - ? headIndex - : indexForReply(replies.length - 1); + 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, ); } useEffect(() { void onPositionsChanged() { - if (threadTailIsVisible()) followsThreadTail.value = true; + if (!userOptedOutOfTailFollow.value && threadTailIsVisible()) { + followsThreadTail.value = true; + } } itemPositionsListener.itemPositions.addListener(onPositionsChanged); @@ -148,8 +145,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, @@ -160,70 +155,138 @@ class ThreadDetailPage extends HookConsumerWidget { ? null : indexForReply(chronologicalIndex); 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); - 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]); - // 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 didEstablishInitialReplies = useRef(hasFetchedReplies); + 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 restoreFollow = false, + bool animate = true, + }) { + if (!initialTailSettle.isComplete || + viewportHeight <= 0 || + !currentIntentAllowsTailMutation( + allowIdleDetached: allowIdleDetached, + )) { + return; + } + if (!allowIdleDetached) followsThreadTail.value = true; + tailIntent.schedule( + allowed: true, + revalidate: () => + context.mounted && + itemScrollController.isAttached && + currentIntentAllowsTailMutation( + allowIdleDetached: allowIdleDetached, + ), + action: () { + final lastIndex = _threadTailIndex(replies.length); + if (restoreFollow) { + userOptedOutOfTailFollow.value = false; + followsThreadTail.value = true; + } + if (animate) { + itemScrollController.scrollTo( + index: lastIndex, + alignment: topOverlayFraction, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + ); + } else { + itemScrollController.jumpTo( + index: lastIndex, + alignment: topOverlayFraction, + ); + } + }, + ); + } + useEffect(() { - // The first authoritative query result is hydration, not a live arrival. - // Establish the baseline without moving the user away from the head. - if (!hasFetchedReplies) return null; - if (!didEstablishInitialReplies.value) { - didEstablishInitialReplies.value = true; + if (!hasFetchedReplies || viewportHeight <= 0) return null; + if (isMember && !isArchived && composerDockHeight.value <= 0) { + return null; + } + if (!initialTailSettle.isComplete) { previousReplyCount.value = replies.length; + previousViewportHeight.value = viewportHeight; + initialTailSettle.schedule( + context: context, + controller: itemScrollController, + positionsListener: itemPositionsListener, + targetIndex: initialMessageId == null && replies.isNotEmpty + ? indexForReply(replies.length - 1) + : null, + hiddenTopFraction: topOverlayFraction, + hiddenBottomFraction: composerDockHeight.value / viewportHeight, + ); return null; } - 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) { + // 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; - 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); - 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 && 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; - 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, + restoreFollow: hasNewLocalReply, + ); return null; - }, [hasFetchedReplies, replies.length]); + }, [hasFetchedReplies, replies.length, settleGeometry]); final readState = ref.watch(readStateProvider); final visibleReplyReadKey = replies .map((reply) => '${reply.id}:${reply.createdAt}') @@ -241,7 +304,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) @@ -252,72 +314,56 @@ 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) { + listViewport.reportAfterLayout(); final previousHeight = composerDockHeight.value; 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) { + if (heightDelta <= 0 || + !shouldFollowTail || + !viewportHeight.isFinite || + viewportHeight <= 0 || + !initialTailSettle.isComplete) { 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; if (lastPosition == null) return; final targetAlignment = (pendingTailAlignment.value ?? lastPosition.itemLeadingEdge) - - (heightDelta / MediaQuery.sizeOf(context).height); + (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, - ); - }); + ), + ); } - // 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() { - final shouldFollowTail = 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, - ); - }); + listViewport.reportAfterLayout(); + queueTailRealignment(); } useEffect(() { @@ -328,7 +374,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) { @@ -348,162 +393,170 @@ class ThreadDetailPage extends HookConsumerWidget { Column( children: [ Expanded( - child: KeyboardDismissOnDrag( - onUserScrollStart: () { - followsThreadTail.value = false; - pendingTailAlignment.value = null; - }, - child: ScrollablePositionedList.builder( - 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, - 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, - ), - ), - ], - ), - ), - ], - ), - ); - } - - // 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, - ), - ], + child: LaidOutViewportReporter( + viewport: listViewport, + child: KeyboardDismissOnDrag( + onUserScrollStart: () { + initialTailSettle.abandon(); + tailIntent.beginDrag(); + userOptedOutOfTailFollow.value = true; + followsThreadTail.value = false; + pendingTailAlignment.value = null; + }, + 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, ), ); }, + 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; + + 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}'), + 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, + ), + ], + ), + ); + }, + ), ), ), ), 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) @@ -515,16 +568,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', @@ -554,42 +598,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 { @@ -702,15 +710,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/shared/widgets/keyboard_dismiss_on_drag.dart b/mobile/lib/shared/widgets/keyboard_dismiss_on_drag.dart index 93371bdd0..c38608c3c 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,24 @@ 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) { + // 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) { - 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 15cf79a31..fbb12f717 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'; @@ -33,6 +34,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'; @@ -176,6 +178,7 @@ Widget _buildTestable({ ChannelActions Function(Ref ref)? createChannelActions, ReadStateNotifier? readStateNotifier, _FakeMessagesNotifier? messagesNotifier, + _FakeTypingNotifier? typingNotifier, String? canvasContent, String? initialMessageId, String? initialThreadRootId, @@ -197,7 +200,7 @@ Widget _buildTestable({ ).overrideWith(() => fakeMessagesNotifier), channelTypingProvider( _channelId, - ).overrideWith(() => _FakeTypingNotifier(typing)), + ).overrideWith(() => typingNotifier ?? _FakeTypingNotifier(typing)), userCacheProvider.overrideWith( () => userCacheNotifier ?? _FakeUserCacheNotifier(users), ), @@ -4085,8 +4088,617 @@ 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( - 'initial thread hydration keeps the head visible instead of following the tail', + '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('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( + '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('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 { + 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 { + 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 { + 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( + 'slow initial hydration requests the frame that settles the latest reply', (tester) async { final rootEvent = _textMsg( id: 'thread-root', @@ -4120,6 +4732,74 @@ void main() { ); 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 { + 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>(); + 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'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + final threadHead = formatTimeline([rootEvent]).single; Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( MaterialPageRoute( @@ -4140,16 +4820,937 @@ void main() { ); completer.complete(replies); + await tester.pump(); + final latestLiveReply = _textMsg( + id: 'reply-live', + pubkey: 'bob', + content: List.filled(10, 'Tall live reply').join('\n'), + 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')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('thread-message-group-reply-live')), findsOneWidget, ); + final listRect = tester.getRect( + find.byKey(const ValueKey('thread-message-list')), + ); + 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(latestRect.bottom, lessThanOrEqualTo(composerTop)); + expect(latestRect.bottom, greaterThan(listRect.center.dy)); + }, + ); + + 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, + ); + + 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, + ); + }); + + 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), + (name: 'writable member', isMember: true, isArchived: false), + ]) { + 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 replyCount = tail.isLong ? 30 : 6; + final replies = [ + for (var i = 0; i < replyCount; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'bob', + content: i == replyCount - 1 && 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, + ), + ); + 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( + 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() { + 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 (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)); + }, + ); + } + + 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)); + }, + ); + } + + 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 { + 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, + ); + + // 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), + ); }, ); @@ -4533,6 +6134,8 @@ class _FakeTypingNotifier extends ChannelTypingNotifier { @override List build() => _entries; + + void setEntries(List entries) => state = entries; } class _SynchronousReadStateNotifier extends ReadStateNotifier { 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 19424b7c8..55f6281e5 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,8 @@ import 'package:flutter_test/flutter_test.dart'; Widget _testable({ required FocusNode focusNode, VoidCallback? onUserScrollStart, + VoidCallback? onUserScrollEnd, + Widget? scrollChild, }) { return MaterialApp( home: Scaffold( @@ -16,12 +18,15 @@ Widget _testable({ Expanded( child: KeyboardDismissOnDrag( onUserScrollStart: onUserScrollStart, - child: ListView( - children: [ - for (var i = 0; i < 40; i++) - SizedBox(height: 60, child: Text('row $i')), - ], - ), + onUserScrollEnd: onUserScrollEnd, + child: + scrollChild ?? + ListView( + children: [ + for (var i = 0; i < 40; i++) + SizedBox(height: 60, child: Text('row $i')), + ], + ), ), ), ], @@ -62,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); @@ -74,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 { @@ -125,6 +140,147 @@ 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('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 {