From 93114c9c65138397de39729fde0a816eb9f314ab Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Wed, 19 Aug 2026 00:01:15 +0100 Subject: [PATCH] Fix mobile Activity thread navigation (#5850) ## Summary - Open Activity items directly in their target thread while preserving Activity as the Back destination. - Fade in a muted target-message highlight after navigation settles, hold it for three seconds, then fade it away. ## Why Activity deep links hydrated the channel before opening the thread, which left the temporary channel route in the navigation stack. The target highlight also appeared before the route settled and remained indefinitely. ## Validation - `just mobile-check` - Full Flutter test suite (1,357 tests) - Signed Profile build installed and launched on a physical iPhone --------- Signed-off-by: kenny lopez Signed-off-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz> Signed-off-by: Princess Donut Signed-off-by: Kenny Lopez Co-authored-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz> --- .../lib/features/activity/activity_page.dart | 4 + .../channels/channel_detail_page.dart | 15 + .../channel_detail_page/message_list.dart | 35 +- .../features/channels/thread_detail_page.dart | 208 +++++-- .../thread_detail_page/thread_message.dart | 29 +- .../shared/widgets/bee_refresh_indicator.dart | 7 +- mobile/lib/shared/widgets/flapping_bee.dart | 4 +- .../features/activity/activity_page_test.dart | 4 + .../channels/channel_detail_page_test.dart | 573 +++++++++++++++++- .../widgets/bee_refresh_indicator_test.dart | 46 +- 10 files changed, 855 insertions(+), 70 deletions(-) diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index dcae998ed0..62dfb18fc7 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -220,6 +220,8 @@ class ActivityPage extends HookConsumerWidget { channel: channel, initialMessageId: target.id, initialThreadRootId: threadRootId, + initialThreadRouteBehavior: + InitialThreadRouteBehavior.replaceCurrentRoute, ), ), ); @@ -240,6 +242,8 @@ class ActivityPage extends HookConsumerWidget { builder: (_) => ChannelDetailPage( channel: channel, initialThreadRootId: draft.threadHeadId, + initialThreadRouteBehavior: + InitialThreadRouteBehavior.replaceCurrentRoute, ), ), ); diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 0e3251c752..a36b951e42 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -123,16 +123,29 @@ int? _channelReadTimestamp({ return dateTimeToUnixSeconds(channel.lastMessageAt); } +/// Controls how a hydrated initial thread is added to the navigation stack. +enum InitialThreadRouteBehavior { + /// Keep the channel route beneath the thread. + push, + + /// Replace the temporary channel route so Back returns to its origin. + replaceCurrentRoute, +} + class ChannelDetailPage extends HookConsumerWidget { final Channel channel; final String? initialMessageId; final String? initialThreadRootId; + /// How the automatically opened initial thread affects the route stack. + final InitialThreadRouteBehavior initialThreadRouteBehavior; + const ChannelDetailPage({ super.key, required this.channel, this.initialMessageId, this.initialThreadRootId, + this.initialThreadRouteBehavior = InitialThreadRouteBehavior.push, }); @override @@ -453,6 +466,8 @@ class ChannelDetailPage extends HookConsumerWidget { allMessages: messages, initialMessageId: initialMessageId, initialThreadRootId: initialThreadRootId, + initialThreadRouteBehavior: + initialThreadRouteBehavior, initialOrdinaryUnreadMessageIds: initialOrdinaryUnreadMessageIds, initialOldestOrdinaryUnreadMessageId: diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index 251e40e2fa..06308063ab 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -5,6 +5,7 @@ class _MessageList extends HookConsumerWidget { final List allMessages; final String? initialMessageId; final String? initialThreadRootId; + final InitialThreadRouteBehavior initialThreadRouteBehavior; final Set initialOrdinaryUnreadMessageIds; final String? initialOldestOrdinaryUnreadMessageId; final Set initialForcedUnreadMessageIds; @@ -23,6 +24,7 @@ class _MessageList extends HookConsumerWidget { required this.allMessages, required this.initialMessageId, required this.initialThreadRootId, + required this.initialThreadRouteBehavior, required this.initialOrdinaryUnreadMessageIds, required this.initialOldestOrdinaryUnreadMessageId, required this.initialForcedUnreadMessageIds, @@ -569,23 +571,30 @@ class _MessageList extends HookConsumerWidget { if (threadHead == null) return null; didOpenInitialThread.value = true; WidgetsBinding.instance.addPostFrameCallback((_) { - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: threadHead, - allMessages: allMessages, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - initialMessageId: initialMessageId, - ), + if (!context.mounted || ModalRoute.of(context)?.isCurrent != true) { + return; + } + final route = MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: allMessages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + initialMessageId: initialMessageId, ), ); + final navigator = Navigator.of(context); + switch (initialThreadRouteBehavior) { + case InitialThreadRouteBehavior.push: + navigator.push(route); + case InitialThreadRouteBehavior.replaceCurrentRoute: + navigator.pushReplacement(route); + } }); return null; - }, [initialThreadRootId, allMessages]); + }, [initialThreadRootId, allMessages, initialThreadRouteBehavior]); useEffect(() { final targetIndex = reversedIndexOf(initialMessageId); diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 007702b5a0..2988633158 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -45,6 +47,11 @@ part 'thread_detail_page/tail_alignment.dart'; part 'thread_detail_page/thread_message.dart'; part 'thread_detail_page/avatar.dart'; +const _landingHighlightDuration = Duration(seconds: 3); +const _landingHighlightDelay = Duration(milliseconds: 50); +const _landingHighlightTransitionDuration = Duration(milliseconds: 300); +const _landingHighlightOpacity = 0.12; + /// Full-screen thread detail page. /// /// Shows the thread head message, direct replies, typing indicators scoped to @@ -80,16 +87,21 @@ class ThreadDetailPage extends HookConsumerWidget { ? appView.viewInsets.bottom / appView.devicePixelRatio : 0.0, ); + useEffect(() { + final session = ref.read(relaySessionProvider.notifier); + return session.registerVisibleChannel(channelId); + }, [channelId]); 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), - ), + final repliesArgs = ThreadRepliesArgs( + channelId: channelId, + rootId: queryRootId, ); + final relayReplyState = ref.watch(threadRepliesProvider(repliesArgs)); + final repliesState = ref.watch(threadRepliesWithLocalProvider(repliesArgs)); // 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 @@ -106,6 +118,12 @@ class ThreadDetailPage extends HookConsumerWidget { }); final fetchedReplies = replyMessages.value; + // A terminal query error cannot produce a more authoritative list. Keep + // loading states provisional, but let the hydrated route snapshot drive + // the one-shot target jump when the relay query has definitively failed. + final canUseMessagesForInitialTarget = + relayReplyState.value != null || + (relayReplyState.hasError && !relayReplyState.retrying); final liveDeletionHidesHead = _isDeletedBy( liveChannelEvents, threadHead.id, @@ -122,6 +140,68 @@ class ThreadDetailPage extends HookConsumerWidget { threadHead, ...fetchedReplies, ]; + final routeAnimation = ModalRoute.of(context)?.animation; + final reducedLandingHighlightMotion = MediaQuery.disableAnimationsOf( + context, + ); + final highlightedMessageId = useState(null); + final initialTargetReadyForHighlight = useState(false); + useEffect( + () { + final messageId = initialMessageId; + if (messageId == null || !initialTargetReadyForHighlight.value) { + return null; + } + var disposed = false; + Timer? revealTimer; + Timer? dismissTimer; + + void revealHighlight() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (disposed) return; + revealTimer = Timer(_landingHighlightDelay, () { + if (disposed) return; + highlightedMessageId.value = messageId; + dismissTimer = Timer( + _landingHighlightDuration + + (reducedLandingHighlightMotion + ? Duration.zero + : _landingHighlightTransitionDuration), + () { + if (!disposed) highlightedMessageId.value = null; + }, + ); + }); + }); + } + + void handleRouteStatus(AnimationStatus status) { + if (status != AnimationStatus.completed) return; + routeAnimation?.removeStatusListener(handleRouteStatus); + revealHighlight(); + } + + if (routeAnimation == null || + routeAnimation.status == AnimationStatus.completed) { + revealHighlight(); + } else { + routeAnimation.addStatusListener(handleRouteStatus); + } + + return () { + disposed = true; + routeAnimation?.removeStatusListener(handleRouteStatus); + revealTimer?.cancel(); + dismissTimer?.cancel(); + }; + }, + [ + initialMessageId, + initialTargetReadyForHighlight.value, + reducedLandingHighlightMotion, + routeAnimation, + ], + ); // Index all messages by parentId so we can find direct children of any // message and compute thread summaries for nested threads. @@ -138,6 +218,7 @@ class ThreadDetailPage extends HookConsumerWidget { final listViewport = useMemoized(LaidOutViewport.new); useEffect(() => listViewport.dispose, [listViewport]); final didJumpToInitialMessage = useRef(false); + final initialHighlightTargetIndex = useState(null); final followsThreadTail = useRef(false); final userOptedOutOfTailFollow = useRef(false); final userDragDetachedTailFollow = useRef(false); @@ -271,42 +352,84 @@ 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, - ); - final targetIndex = messageId == threadHead.id - ? headIndex - : chronologicalIndex < 0 - ? null - : indexForReply(chronologicalIndex); - if (targetIndex == null || didJumpToInitialMessage.value) return null; - didJumpToInitialMessage.value = true; - initialTailSettle.abandon(); - userOptedOutOfTailFollow.value = true; - userDragDetachedTailFollow.value = false; - tailIntent.schedule( - allowed: true, - revalidate: () => - context.mounted && - itemScrollController.isAttached && - !tailIntent.isDragging, - action: () { - // 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. - tailIntent.detach(); - followsThreadTail.value = false; - isAtThreadTail.value = false; - itemScrollController.jumpTo(index: targetIndex, alignment: 0.35); - }, - ); - return null; - }, [initialMessageId, fetchedReplies, replies.length]); + useEffect( + () { + final messageId = initialMessageId; + // Wait for either the authoritative thread query or a terminal query + // error before consuming the one-shot jump. During loading, the fallback + // main-timeline list can contain only the linked reply; after an error, + // that hydrated snapshot is the best available target list. + if (messageId == null || !canUseMessagesForInitialTarget) return null; + final chronologicalIndex = replies.indexWhere( + (reply) => reply.id == messageId, + ); + final targetIndex = messageId == threadHead.id + ? headIndex + : chronologicalIndex < 0 + ? null + : indexForReply(chronologicalIndex); + if (targetIndex == null || didJumpToInitialMessage.value) return null; + didJumpToInitialMessage.value = true; + initialTailSettle.abandon(); + userOptedOutOfTailFollow.value = true; + userDragDetachedTailFollow.value = false; + tailIntent.schedule( + allowed: true, + revalidate: () => + context.mounted && + itemScrollController.isAttached && + !tailIntent.isDragging, + action: () { + // 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. + tailIntent.detach(); + followsThreadTail.value = false; + isAtThreadTail.value = false; + itemScrollController.jumpTo(index: targetIndex, alignment: 0.35); + initialHighlightTargetIndex.value = targetIndex; + }, + ); + return null; + }, + [ + initialMessageId, + canUseMessagesForInitialTarget, + fetchedReplies, + replies.length, + ], + ); + + useEffect( + () { + final targetIndex = initialHighlightTargetIndex.value; + if (targetIndex == null || initialTargetReadyForHighlight.value) { + return null; + } + var completionScheduled = false; + void markReadyAfterTargetLayout() { + if (completionScheduled || + !itemPositionsListener.itemPositions.value.any( + (position) => position.index == targetIndex, + )) { + return; + } + completionScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) initialTargetReadyForHighlight.value = true; + }); + } + + itemPositionsListener.itemPositions.addListener( + markReadyAfterTargetLayout, + ); + markReadyAfterTargetLayout(); + return () => itemPositionsListener.itemPositions.removeListener( + markReadyAfterTargetLayout, + ); + }, + [initialHighlightTargetIndex.value, initialTargetReadyForHighlight.value], + ); // 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 @@ -633,7 +756,7 @@ class ThreadDetailPage extends HookConsumerWidget { currentPubkey: currentPubkey, showAuthor: true, isHighlighted: - liveHead.id == initialMessageId, + liveHead.id == highlightedMessageId.value, allMessages: allMsgs, isMember: isMember, isArchived: isArchived, @@ -716,7 +839,8 @@ class ThreadDetailPage extends HookConsumerWidget { channelId: channelId, currentPubkey: currentPubkey, showAuthor: showAuthor, - isHighlighted: reply.id == initialMessageId, + isHighlighted: + reply.id == highlightedMessageId.value, allMessages: allMsgs, isMember: isMember, isArchived: isArchived, diff --git a/mobile/lib/features/channels/thread_detail_page/thread_message.dart b/mobile/lib/features/channels/thread_detail_page/thread_message.dart index db7d1ba0b4..89bdd40998 100644 --- a/mobile/lib/features/channels/thread_detail_page/thread_message.dart +++ b/mobile/lib/features/channels/thread_detail_page/thread_message.dart @@ -92,14 +92,37 @@ class _ThreadMessage extends HookConsumerWidget { ); } + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final highlightController = useAnimationController( + duration: _landingHighlightTransitionDuration, + ); + final highlightProgress = useAnimation(highlightController); + useEffect(() { + if (reducedMotion) { + highlightController.value = isHighlighted ? 1 : 0; + } else { + unawaited( + highlightController.animateTo( + isHighlighted ? 1 : 0, + duration: _landingHighlightTransitionDuration, + curve: Curves.easeOutCubic, + ), + ); + } + return null; + }, [highlightController, isHighlighted, reducedMotion]); + final highlightColor = highlightProgress == 0 + ? Colors.transparent + : context.colors.primary.withValues( + alpha: _landingHighlightOpacity * highlightProgress, + ); + return Padding( padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), child: DecoratedBox( key: ValueKey('thread-message-${message.id}'), decoration: BoxDecoration( - color: isHighlighted - ? context.colors.primary.withValues(alpha: 0.12) - : Colors.transparent, + color: highlightColor, borderRadius: BorderRadius.circular(Radii.md), ), child: Material( diff --git a/mobile/lib/shared/widgets/bee_refresh_indicator.dart b/mobile/lib/shared/widgets/bee_refresh_indicator.dart index 4a9802f0da..f5c71adc17 100644 --- a/mobile/lib/shared/widgets/bee_refresh_indicator.dart +++ b/mobile/lib/shared/widgets/bee_refresh_indicator.dart @@ -385,10 +385,9 @@ class BeeRefreshIndicator extends HookConsumerWidget { width: _beeWidth, color: context.colors.primary, flapAmount: flapAmount, - eyeProgress: - !isLoading && - !showEyeEmoji && - pupilProgress > 0 + eyeProgress: showEyeEmoji + ? 1 + : !isLoading && pupilProgress > 0 ? pupilProgress : null, ), diff --git a/mobile/lib/shared/widgets/flapping_bee.dart b/mobile/lib/shared/widgets/flapping_bee.dart index 9a99bd8796..a9057b60f2 100644 --- a/mobile/lib/shared/widgets/flapping_bee.dart +++ b/mobile/lib/shared/widgets/flapping_bee.dart @@ -137,7 +137,9 @@ class _FlappingBeePainter extends CustomPainter { canvas.drawPath(finishedMark, Paint()..color = color); if (eyeProgress case final progress?) { - final pupilRadius = 20 * progress.clamp(0.0, 1.0); + // The eye cutouts are 54px wide. A full pupil must reach their 27px + // radius so the emoji-eye overlay never exposes the background beneath. + final pupilRadius = 27 * progress.clamp(0.0, 1.0); final pupilPaint = Paint()..color = color; canvas ..drawCircle(const Offset(193.3, 84.4), pupilRadius, pupilPaint) diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index 94d040526d..ad878b309e 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -684,6 +684,10 @@ void main() { expect(page.channel.id, 'ch1'); expect(page.initialThreadRootId, 'parent-reply'); expect(page.initialMessageId, 'reply-event'); + expect( + page.initialThreadRouteBehavior, + InitialThreadRouteBehavior.replaceCurrentRoute, + ); }); testWidgets('thread filter matches grouped thread replies', (tester) async { diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 826a419d8d..f5d54c257a 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -187,12 +187,20 @@ Widget _buildTestable({ String? canvasContent, String? initialMessageId, String? initialThreadRootId, + InitialThreadRouteBehavior initialThreadRouteBehavior = + InitialThreadRouteBehavior.push, Map> threadReplies = const {}, Map>> pendingThreadReplies = const {}, + Map> Function()> threadReplyLoaders = + const {}, + Map> localThreadReplies = const {}, TextScaler textScaler = TextScaler.noScaling, bool disableAnimations = false, + bool disableRetries = false, + Duration? Function(int retryCount, Object error)? providerRetry, RelaySessionNotifier? relaySessionNotifier, http.Client? mediaClient, + Widget? home, }) { final resolvedChannel = channel ?? _testChannel; final fakeChannelsNotifier = @@ -200,6 +208,7 @@ Widget _buildTestable({ final fakeMessagesNotifier = messagesNotifier ?? _FakeMessagesNotifier(messages); return ProviderScope( + retry: providerRetry ?? (disableRetries ? (_, _) => null : null), overrides: [ channelMessagesProvider( _channelId, @@ -240,6 +249,19 @@ Widget _buildTestable({ threadRepliesProvider( ThreadRepliesArgs(channelId: _channelId, rootId: entry.key), ).overrideWith((ref) => entry.value), + for (final entry in threadReplyLoaders.entries) + threadRepliesProvider( + ThreadRepliesArgs(channelId: _channelId, rootId: entry.key), + ).overrideWith((ref) => entry.value()), + for (final entry in localThreadReplies.entries) + threadLocalRepliesProvider( + ThreadRepliesArgs(channelId: _channelId, rootId: entry.key), + ).overrideWith( + () => _FakeThreadLocalRepliesNotifier( + ThreadRepliesArgs(channelId: _channelId, rootId: entry.key), + entry.value, + ), + ), // Stub the relay client provider so preloadMembers doesn't crash. relayClientProvider.overrideWithValue( RelayClient(baseUrl: 'http://localhost:3000'), @@ -265,11 +287,14 @@ Widget _buildTestable({ child: child!, ), navigatorObservers: navigatorObservers, - home: ChannelDetailPage( - channel: resolvedChannel, - initialMessageId: initialMessageId, - initialThreadRootId: initialThreadRootId, - ), + home: + home ?? + ChannelDetailPage( + channel: resolvedChannel, + initialMessageId: initialMessageId, + initialThreadRootId: initialThreadRootId, + initialThreadRouteBehavior: initialThreadRouteBehavior, + ), ), ); } @@ -4434,6 +4459,372 @@ void main() { }); group('Deep-link navigation', () { + testWidgets('fades the target highlight in after the thread route lands', ( + tester, + ) async { + final root = _textMsg( + id: 'root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final target = _textMsg( + id: 'target', + pubkey: 'bob', + content: 'Target reply', + createdAt: 1100, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + final timelineMessages = formatTimeline([root, target]); + final threadHead = timelineMessages.firstWhere( + (message) => message.id == root.id, + ); + + await tester.pumpWidget( + _buildTestable( + messages: [root, target], + threadReplies: { + 'root': [target], + }, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: timelineMessages, + channelId: _testChannel.id, + currentPubkey: null, + isMember: true, + isArchived: false, + initialMessageId: 'target', + ), + ), + ), + child: const Text('Open highlighted thread'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open highlighted thread')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1)); + + final threadRoute = + ModalRoute.of(tester.element(find.byType(ThreadDetailPage)))! + as MaterialPageRoute; + expect(threadRoute.animation!.status, AnimationStatus.forward); + final transitionDecoration = + tester + .widget( + find.byKey(const ValueKey('thread-message-target')), + ) + .decoration + as BoxDecoration; + expect(transitionDecoration.color, Colors.transparent); + + await tester.pump(threadRoute.transitionDuration); + expect(threadRoute.animation!.status, AnimationStatus.completed); + await tester.pump(); + await tester.pump(); + final landedDecoration = + tester + .widget( + find.byKey(const ValueKey('thread-message-target')), + ) + .decoration + as BoxDecoration; + expect(landedDecoration.color, Colors.transparent); + + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(const Duration(milliseconds: 150)); + final enteringDecoration = + tester + .widget( + find.byKey(const ValueKey('thread-message-target')), + ) + .decoration + as BoxDecoration; + expect(enteringDecoration.color!.a, greaterThan(0)); + expect(enteringDecoration.color!.a, lessThan(0.12)); + + await tester.pump(const Duration(milliseconds: 150)); + final visibleDecoration = + tester + .widget( + find.byKey(const ValueKey('thread-message-target')), + ) + .decoration + as BoxDecoration; + expect(visibleDecoration.color!.a, closeTo(0.12, 0.001)); + }); + + testWidgets('waits for a delayed target jump before highlighting', ( + tester, + ) async { + final root = _textMsg( + id: 'root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final replies = [ + for (var i = 0; i < 40; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'bob', + content: 'Reply $i', + createdAt: 1100 + i, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]; + final timelineMessages = formatTimeline([root, ...replies]); + final threadHead = timelineMessages.first; + final replyCompleter = Completer>(); + + await tester.pumpWidget( + _buildTestable( + messages: [root], + pendingThreadReplies: {'root': replyCompleter.future}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: timelineMessages, + channelId: _testChannel.id, + currentPubkey: null, + isMember: true, + isArchived: false, + initialMessageId: 'reply-30', + ), + ), + ), + child: const Text('Open delayed thread'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open delayed thread')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 4)); + + expect( + find.byKey(const ValueKey('thread-message-group-reply-30')), + findsNothing, + ); + + replyCompleter.complete(replies); + await tester.pumpAndSettle(); + + final target = find.byKey(const ValueKey('thread-message-reply-30')); + expect(target, findsOneWidget); + final landedDecoration = + tester.widget(target).decoration as BoxDecoration; + expect(landedDecoration.color, Colors.transparent); + + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(const Duration(milliseconds: 150)); + final enteringDecoration = + tester.widget(target).decoration as BoxDecoration; + expect(enteringDecoration.color!.a, greaterThan(0)); + expect(enteringDecoration.color!.a, lessThan(0.12)); + }); + + testWidgets('waits for a retry before jumping to a hydrated target', ( + tester, + ) async { + final root = _textMsg( + id: 'root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final target = _textMsg( + id: 'target', + pubkey: 'bob', + content: 'Hydrated target', + createdAt: 1400, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + final earlierReplies = [ + for (var i = 0; i < 30; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'bob', + content: 'Reply $i', + createdAt: 1100 + i, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]; + final timelineMessages = formatTimeline([root, target]); + final firstAttempt = Completer>(); + var attempts = 0; + + await tester.pumpWidget( + _buildTestable( + messages: [root, target], + providerRetry: (retryCount, _) => + retryCount == 0 ? const Duration(seconds: 30) : null, + localThreadReplies: { + 'root': [target], + }, + threadReplyLoaders: { + 'root': () { + attempts++; + if (attempts == 1) return firstAttempt.future; + return Future.value([...earlierReplies, target]); + }, + }, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + home: ThreadDetailPage( + threadHead: timelineMessages.first, + allMessages: timelineMessages, + channelId: _testChannel.id, + currentPubkey: null, + isMember: true, + isArchived: false, + initialMessageId: 'target', + ), + ), + ); + await tester.pump(); + firstAttempt.completeError(Exception('transient thread query failure')); + await tester.pump(); + await tester.pump(); + + final targetFinder = find.byKey(const ValueKey('thread-message-target')); + expect(targetFinder, findsOneWidget); + final retryingDecoration = + tester.widget(targetFinder).decoration as BoxDecoration; + expect(retryingDecoration.color, Colors.transparent); + expect(attempts, 1); + + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(const Duration(milliseconds: 150)); + final stillRetryingDecoration = + tester.widget(targetFinder).decoration as BoxDecoration; + expect(stillRetryingDecoration.color, Colors.transparent); + + await tester.pump(const Duration(milliseconds: 2800)); + expect(attempts, 1); + final expiredJumpDecoration = + tester.widget(targetFinder).decoration as BoxDecoration; + expect(expiredJumpDecoration.color, Colors.transparent); + + await tester.pump(const Duration(seconds: 30)); + await tester.pumpAndSettle(); + + expect(attempts, 2); + expect( + find.byKey(const ValueKey('thread-message-group-target')), + findsOneWidget, + ); + final landedDecoration = + tester.widget(targetFinder).decoration as BoxDecoration; + expect(landedDecoration.color, Colors.transparent); + + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(const Duration(milliseconds: 150)); + final highlightedDecoration = + tester.widget(targetFinder).decoration as BoxDecoration; + expect(highlightedDecoration.color!.a, greaterThan(0)); + }); + + testWidgets('highlights a hydrated target after the thread query fails', ( + tester, + ) async { + final root = _textMsg( + id: 'root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final target = _textMsg( + id: 'target', + pubkey: 'bob', + content: 'Hydrated target', + createdAt: 1100, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + final timelineMessages = formatTimeline([root, target]); + final replyCompleter = Completer>(); + + await tester.pumpWidget( + _buildTestable( + messages: [root, target], + pendingThreadReplies: {'root': replyCompleter.future}, + disableRetries: true, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + home: ThreadDetailPage( + threadHead: timelineMessages.first, + allMessages: timelineMessages, + channelId: _testChannel.id, + currentPubkey: null, + isMember: true, + isArchived: false, + initialMessageId: 'target', + ), + ), + ); + await tester.pumpAndSettle(); + + final targetFinder = find.byKey(const ValueKey('thread-message-target')); + expect(targetFinder, findsOneWidget); + final loadingDecoration = + tester.widget(targetFinder).decoration as BoxDecoration; + expect(loadingDecoration.color, Colors.transparent); + + replyCompleter.completeError(Exception('thread query failed')); + for (var i = 0; i < 8; i++) { + await tester.pump(); + } + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(const Duration(milliseconds: 150)); + + final highlightedDecoration = + tester.widget(targetFinder).decoration as BoxDecoration; + expect(highlightedDecoration.color!.a, greaterThan(0)); + expect(highlightedDecoration.color!.a, lessThan(0.12)); + }); + testWidgets('opens a nested reply in its direct-parent thread', ( tester, ) async { @@ -4492,7 +4883,147 @@ void main() { find.byKey(const ValueKey('thread-message-target')), ); final decoration = highlighted.decoration as BoxDecoration; - expect(decoration.color, isNot(Colors.transparent)); + final initialHighlight = decoration.color!; + expect(initialHighlight, isNot(Colors.transparent)); + expect(initialHighlight.a, closeTo(0.12, 0.001)); + + await tester.pump(const Duration(milliseconds: 2999)); + final heldDecoration = + tester + .widget( + find.byKey(const ValueKey('thread-message-target')), + ) + .decoration + as BoxDecoration; + expect(heldDecoration.color, initialHighlight); + + await tester.pump(const Duration(milliseconds: 1)); + await tester.pump(const Duration(milliseconds: 150)); + + final fadingDecoration = + tester + .widget( + find.byKey(const ValueKey('thread-message-target')), + ) + .decoration + as BoxDecoration; + expect(fadingDecoration.color!.a, greaterThan(0)); + expect(fadingDecoration.color!.a, lessThan(initialHighlight.a)); + + await tester.pump(const Duration(milliseconds: 150)); + final dismissedDecoration = + tester + .widget( + find.byKey(const ValueKey('thread-message-target')), + ) + .decoration + as BoxDecoration; + expect(dismissedDecoration.color, Colors.transparent); + }); + + testWidgets('does not replace a newer route after delayed hydration', ( + tester, + ) async { + final root = _textMsg( + id: 'root', + pubkey: 'alice', + content: 'Thread root', + ); + final messagesNotifier = _FakeMessagesNotifier(const []); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + messagesNotifier: messagesNotifier, + initialThreadRootId: 'root', + initialThreadRouteBehavior: + InitialThreadRouteBehavior.replaceCurrentRoute, + ), + ); + await tester.pumpAndSettle(); + + final navigator = Navigator.of( + tester.element(find.byType(ChannelDetailPage)), + ); + messagesNotifier.setMessages([root]); + navigator.push( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('New destination')), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('New destination'), findsOneWidget); + expect(find.byType(ThreadDetailPage), findsNothing); + }); + + testWidgets('replaces a temporary channel route for an initial thread', ( + tester, + ) async { + final root = _textMsg( + id: 'root', + pubkey: 'alice', + content: 'Thread root', + ); + final target = _textMsg( + id: 'target', + pubkey: 'bob', + content: 'Target reply', + createdAt: 1100, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + final relaySession = _TrackingRelaySession(); + + await tester.pumpWidget( + _buildTestable( + messages: [root, target], + relaySessionNotifier: relaySession, + threadReplies: { + 'root': [target], + }, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelDetailPage( + channel: _testChannel, + initialMessageId: 'target', + initialThreadRootId: 'root', + initialThreadRouteBehavior: + InitialThreadRouteBehavior.replaceCurrentRoute, + ), + ), + ), + child: const Text('Open activity thread'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open activity thread')); + await tester.pumpAndSettle(); + + expect(find.byType(ThreadDetailPage), findsOneWidget); + expect(find.byType(ChannelDetailPage), findsNothing); + expect(relaySession.visibleChannels, [_testChannel.id]); + + await tester.pageBack(); + await tester.pumpAndSettle(); + + expect(find.text('Open activity thread'), findsOneWidget); + expect(find.byType(ChannelDetailPage), findsNothing); + expect(relaySession.visibleChannels, isEmpty); }); }); @@ -7292,6 +7823,15 @@ Channel _channel({required String id, required String name}) => Channel( isMember: true, ); +class _FakeThreadLocalRepliesNotifier extends ThreadLocalRepliesNotifier { + final List _replies; + + _FakeThreadLocalRepliesNotifier(super.args, this._replies); + + @override + List build() => _replies; +} + class _FakeMessagesNotifier extends ChannelMessagesNotifier { List _messages; bool _hasLoadedMessages; @@ -7343,6 +7883,27 @@ class _ErrorMessagesNotifier extends ChannelMessagesNotifier { AsyncError('Connection failed', StackTrace.current); } +class _TrackingRelaySession extends RelaySessionNotifier { + final visibleChannels = []; + + @override + SessionState build() => + const SessionState(status: SessionStatus.disconnected); + + @override + void Function() registerVisibleChannel(String channelId) { + final release = super.registerVisibleChannel(channelId); + visibleChannels.add(channelId); + var released = false; + return () { + if (released) return; + released = true; + visibleChannels.remove(channelId); + release(); + }; + } +} + class _ReconnectingRelaySession extends RelaySessionNotifier { @override SessionState build() => diff --git a/mobile/test/shared/widgets/bee_refresh_indicator_test.dart b/mobile/test/shared/widgets/bee_refresh_indicator_test.dart index de440ebb62..1336e9f9d1 100644 --- a/mobile/test/shared/widgets/bee_refresh_indicator_test.dart +++ b/mobile/test/shared/widgets/bee_refresh_indicator_test.dart @@ -1,14 +1,56 @@ import 'dart:async'; +import 'dart:ui' as ui; import 'package:buzz/shared/widgets/bee_refresh_indicator.dart'; import 'package:buzz/shared/widgets/flapping_bee.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../helpers/widget_helpers.dart'; void main() { + testWidgets('full eye progress fills each eye cutout to its edge', ( + tester, + ) async { + const beeKey = ValueKey('full-eye-bee'); + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: FlappingBee( + key: beeKey, + width: 466, + color: Colors.black, + flapAmount: 0, + eyeProgress: 1, + ), + ), + ), + ); + + final boundary = tester.renderObject( + find.descendant( + of: find.byKey(beeKey), + matching: find.byType(RepaintBoundary), + ), + ); + final bytes = await tester.runAsync(() async { + final image = await boundary.toImage(); + final data = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + image.dispose(); + return data; + }); + expect(bytes, isNotNull); + + int alphaAt(int x, int y) => bytes!.getUint8(((y * 466) + x) * 4 + 3); + + // These points sit inside the 27px eye cutouts but outside the old 20px + // pupil radius, directly covering the light rings seen behind the emoji. + expect(alphaAt(217, 84), 255); + expect(alphaAt(300, 84), 255); + }); + testWidgets('shows the bee while pulling to refresh', (tester) async { const contentKey = ValueKey('loading-content'); var refreshes = 0; @@ -190,7 +232,7 @@ void main() { ); await tester.pump(); - expect(tester.widget(beeFinder).eyeProgress, isNull); + expect(tester.widget(beeFinder).eyeProgress, 1); expect( find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsOneWidget, @@ -239,6 +281,7 @@ void main() { find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsOneWidget, ); + expect(tester.widget(beeFinder).eyeProgress, 1); expect(hapticCalls, hasLength(3)); await secondGesture.moveBy( @@ -250,6 +293,7 @@ void main() { find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsOneWidget, ); + expect(tester.widget(beeFinder).eyeProgress, 1); expect(hapticCalls, hasLength(3)); await secondGesture.up();