diff --git a/mobile/lib/features/channels/initial_thread_tail_settle.dart b/mobile/lib/features/channels/initial_thread_tail_settle.dart new file mode 100644 index 00000000000..2e41d18e5b0 --- /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 00000000000..12fa4a2f88e --- /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 00000000000..e4d0f76ff3d --- /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 2ce862ea711..9de8dc861f6 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'), + 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-${liveHead.id}'), - padding: const EdgeInsets.only(bottom: Grid.xs), + key: ValueKey('thread-message-group-${reply.id}'), + padding: EdgeInsets.zero, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - DayDivider( - label: formatDayHeading(liveHead.createdAt), - ), + if (showDayDivider) + DayDivider( + label: formatDayHeading(reply.createdAt), + ), _ThreadMessage( - message: liveHead, + message: reply, channelNames: channelNamesMap, channelId: channelId, currentPubkey: currentPubkey, - showAuthor: true, - isHighlighted: liveHead.id == initialMessageId, + showAuthor: showAuthor, + isHighlighted: reply.id == initialMessageId, allMessages: allMsgs, isMember: isMember, isArchived: isArchived, - isThreadHead: true, ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: Grid.xxs, - ), - child: Row( - children: [ - Text( - '${replies.length} ${replies.length == 1 ? 'reply' : 'replies'}', - style: context.textTheme.labelMedium - ?.copyWith( - color: - context.colors.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(width: Grid.xxs), - Expanded( - child: Divider( - color: context.colors.outlineVariant, - ), - ), - ], + if (nestedSummary != null) + _NestedThreadSummaryRow( + summary: nestedSummary, + replyMessage: reply, + allMessages: allMsgs, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, ), - ), ], ), ); - } - - // Chronological list: index 1 = oldest reply. - final chronIdx = index - 1; - final reply = replies[chronIdx]; - final prevReply = chronIdx > 0 - ? replies[chronIdx - 1] - : null; - final previousMessage = prevReply ?? liveHead; - final showDayDivider = !isSameDay( - previousMessage.createdAt, - reply.createdAt, - ); - final showAuthor = - prevReply == null || - showDayDivider || - prevReply.pubkey.toLowerCase() != - reply.pubkey.toLowerCase() || - (reply.createdAt - prevReply.createdAt) > 300; - - // Check if this reply itself has children (nested thread). - final nestedChildren = childrenByParent[reply.id]; - final nestedSummary = - nestedChildren != null && nestedChildren.isNotEmpty - ? _buildNestedSummary(reply.id, nestedChildren) - : null; - - return Padding( - key: ValueKey('thread-message-group-${reply.id}'), - // Tail spacing comes from the list's own bottom padding now - // that the list runs top-down; the reversed list used to - // need it here because item 0 sat against the composer. - padding: EdgeInsets.zero, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showDayDivider) - DayDivider( - label: formatDayHeading(reply.createdAt), - ), - _ThreadMessage( - message: reply, - channelNames: channelNamesMap, - channelId: channelId, - currentPubkey: currentPubkey, - showAuthor: showAuthor, - isHighlighted: reply.id == initialMessageId, - allMessages: allMsgs, - isMember: isMember, - isArchived: isArchived, - ), - if (nestedSummary != null) - _NestedThreadSummaryRow( - summary: nestedSummary, - replyMessage: reply, - allMessages: allMsgs, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - ], - ), - ); - }, + }, + ), ), ), ), 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 93371bdd0bd..c38608c3cc6 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 15cf79a3133..fbb12f71771 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,9 +4088,1558 @@ void main() { ); }); + for (final replyCount in [0, 1]) { + testWidgets( + 'cached writable $replyCount-reply thread defers dock correction until measured', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final rootEvent = _textMsg( + id: 'thread-root', + pubkey: 'alice', + content: 'Short root', + createdAt: 1000, + ); + final replies = [ + if (replyCount == 1) + _textMsg( + id: 'reply-0', + pubkey: 'bob', + content: 'Short reply', + createdAt: 1100, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + ]; + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + threadReplies: {'thread-root': replies}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + final routeMessages = formatTimeline([rootEvent, ...replies]); + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: routeMessages.first, + allMessages: routeMessages, + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + ), + ); + + await tester.pump(); + expect(tester.takeException(), isNull); + final earlyDock = tester.widget( + find.byType(ComposerDockSizeReporter).last, + ); + + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + earlyDock.onHeightChanged(200); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + final composerSurface = find.byKey( + const ValueKey('composer-surface'), + ); + final tail = find.byKey( + ValueKey( + replyCount == 0 + ? 'thread-message-group-thread-root' + : 'thread-message-group-reply-0', + ), + ); + expect(tail, findsOneWidget); + expect( + tester.getTopLeft(tail).dy, + greaterThanOrEqualTo(frostedAppBarHeight(tester.element(tail))), + ); + expect( + tester.getBottomLeft(tail).dy, + lessThanOrEqualTo(tester.getTopLeft(composerSurface).dy), + ); + }, + ); + } + + testWidgets( + 'cached initial thread settles between the app bar and measured composer', + (tester) async { + 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', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final replies = [ + for (var i = 0; i < 30; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'bob', + content: 'Reply $i', + createdAt: 1100 + i, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + ]; + final completer = Completer>(); + + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + pendingThreadReplies: {'thread-root': completer.future}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + final threadHead = formatTimeline([rootEvent]).single; + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: [threadHead], + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + + completer.complete(replies); + await tester.pump(); + expect(tester.binding.hasScheduledFrame, isTrue); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('thread-message-group-thread-root')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('thread-message-group-reply-29')), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'initial thread hydration settles on the latest reply after pagination', + (tester) async { + 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( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: [threadHead], + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('thread-message-group-thread-root')), + findsOneWidget, + ); + + 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( - 'initial thread hydration keeps the head visible instead of following the tail', + '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', @@ -4106,12 +5658,11 @@ void main() { ], ), ]; - final completer = Completer>(); await tester.pumpWidget( _buildTestable( messages: [rootEvent], - pendingThreadReplies: {'thread-root': completer.future}, + threadReplies: {'thread-root': replies}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -4134,22 +5685,72 @@ void main() { ), ); 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-thread-root')), - findsOneWidget, + 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(); - completer.complete(replies); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); await tester.pumpAndSettle(); - expect( - find.byKey(const ValueKey('thread-message-group-thread-root')), - findsOneWidget, - ); 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 19424b7c82e..55f6281e50f 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 {