From 32ead931011bd95d3423d4db17b087fe110808a4 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 27 Jul 2026 10:50:55 -0700 Subject: [PATCH] fix(mobile): tapping threaded message in Inbox navigates to top level of channel (#2103) ## The bug When you tap an item in the Activity inbox that refers to a message inside a thread (for example, someone replied to you or mentioned you in a thread reply), the app opened the channel at its top level. It did not open the thread, and it did not show you the message the notification was about. You had to hunt for the reply manually. ## The fix Activity items now keep track of two things: the root message of the thread and the specific message that triggered the notification. Tapping the item now: 1. Opens the thread detail view for that thread (instead of the channel's top level). 2. Scrolls to the specific message that triggered the notification. 3. Briefly highlights that message so it is easy to spot. This works for both direct replies and replies nested deeper in a thread, and it fetches the thread from the relay if it is not already loaded (for example, right after app launch). ## Testing - Flutter analyzer - 64 focused mobile tests covering direct and nested thread markers plus Activity navigation - Full pre-push suite (mobile, desktop, Rust, and Tauri tests) ## Manual verification Open Activity, tap a mention for a reply inside a thread, and confirm Buzz opens that thread, scrolls to the reply, and highlights it. --------- Signed-off-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub15w828kxsxu2684ynste0uah2jwkgatd99flt7ds4523hzm8ju6cshdr8hh Co-authored-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Co-authored-by: npub15w828kxsxu2684ynste0uah2jwkgatd99flt7ds4523hzm8ju6cshdr8hh --- .../lib/features/activity/activity_page.dart | 2 +- mobile/lib/features/activity/feed_item.dart | 11 ++ .../features/channels/thread_detail_page.dart | 186 ++++++++++-------- .../features/activity/activity_page_test.dart | 45 ++++- .../features/activity/feed_item_test.dart | 42 ++++ .../channels/channel_detail_page_test.dart | 87 +++++++- 6 files changed, 284 insertions(+), 89 deletions(-) diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index ee17eb430..6f38681b2 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -149,7 +149,7 @@ class ActivityPage extends HookConsumerWidget { final thread = threadReferenceOf(target.tags); final threadRootId = isBroadcastReply(target.tags) ? null - : (thread.rootId ?? thread.parentId); + : thread.parentId; Navigator.of(context).push( MaterialPageRoute( diff --git a/mobile/lib/features/activity/feed_item.dart b/mobile/lib/features/activity/feed_item.dart index 5d3d1012f..84aa1eba4 100644 --- a/mobile/lib/features/activity/feed_item.dart +++ b/mobile/lib/features/activity/feed_item.dart @@ -41,6 +41,17 @@ class FeedItem { category: json['category'] as String, ); + /// The message whose thread directly contains this item, when this event is + /// a reply. For nested replies this is the direct parent, not the outer root. + String? get threadHeadId { + for (final tag in tags) { + if (tag.length >= 4 && tag[0] == 'e' && tag[3] == 'reply') { + return tag[1]; + } + } + return null; + } + /// Human-readable headline based on event kind and category. String get headline { switch (kind) { diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index a81de36e0..d00973b4d 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -53,9 +53,13 @@ class ThreadDetailPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + // 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: threadHead.id), + ThreadRepliesArgs(channelId: channelId, rootId: queryRootId), ), ); final replyMessages = repliesState.whenData((events) { @@ -65,7 +69,10 @@ class ThreadDetailPage extends HookConsumerWidget { final fetchedReplies = replyMessages.value; final allMsgs = fetchedReplies == null ? allMessages - : [threadHead, ...fetchedReplies]; + : [ + threadHead, + ...fetchedReplies.where((message) => message.id != threadHead.id), + ]; // Index all messages by parentId so we can find direct children of any // message and compute thread summaries for nested threads. @@ -175,6 +182,7 @@ class ThreadDetailPage extends HookConsumerWidget { channelId: channelId, currentPubkey: currentPubkey, showAuthor: true, + isHighlighted: liveHead.id == initialMessageId, allMessages: allMsgs, isMember: isMember, isArchived: isArchived, @@ -237,6 +245,7 @@ class ThreadDetailPage extends HookConsumerWidget { channelId: channelId, currentPubkey: currentPubkey, showAuthor: showAuthor, + isHighlighted: reply.id == initialMessageId, allMessages: allMsgs, isMember: isMember, isArchived: isArchived, @@ -422,6 +431,7 @@ class _ThreadMessage extends ConsumerWidget { final String channelId; final String? currentPubkey; final bool showAuthor; + final bool isHighlighted; final List? allMessages; final bool isMember; final bool isArchived; @@ -432,6 +442,7 @@ class _ThreadMessage extends ConsumerWidget { required this.channelId, required this.currentPubkey, required this.showAuthor, + this.isHighlighted = false, this.allMessages, this.isMember = false, this.isArchived = false, @@ -476,97 +487,110 @@ class _ThreadMessage extends ConsumerWidget { isMember: isMember, isArchived: isArchived, ), - child: Padding( - padding: EdgeInsets.only(top: showAuthor ? Grid.xs : Grid.quarter), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - GestureDetector( - onTap: () => showUserProfileSheet(context, message.pubkey), - child: _Avatar(profile: profile, pubkey: message.pubkey), - ) - else - const SizedBox(width: 36), - const SizedBox(width: Grid.xxs), - Expanded( - child: Transform.translate( - offset: Offset(0, showAuthor ? -Grid.quarter : 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - Padding( - padding: const EdgeInsets.only(bottom: Grid.quarter), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - GestureDetector( - onTap: () => - showUserProfileSheet(context, message.pubkey), - child: Text( - displayName, - style: context.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - color: context.colors.onSurface, + child: DecoratedBox( + key: ValueKey('thread-message-${message.id}'), + decoration: BoxDecoration( + color: isHighlighted + ? context.colors.primary.withValues(alpha: 0.12) + : Colors.transparent, + borderRadius: BorderRadius.circular(Grid.half), + ), + child: Padding( + padding: EdgeInsets.only(top: showAuthor ? Grid.xs : Grid.quarter), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + GestureDetector( + onTap: () => showUserProfileSheet(context, message.pubkey), + child: _Avatar(profile: profile, pubkey: message.pubkey), + ) + else + const SizedBox(width: 36), + const SizedBox(width: Grid.xxs), + Expanded( + child: Transform.translate( + offset: Offset(0, showAuthor ? -Grid.quarter : 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + Padding( + padding: const EdgeInsets.only(bottom: Grid.quarter), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + GestureDetector( + onTap: () => showUserProfileSheet( + context, + message.pubkey, + ), + child: Text( + displayName, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: context.colors.onSurface, + ), ), ), - ), - const SizedBox(width: Grid.xxs), - Text( - formatMessageTime(message.createdAt), - style: context.textTheme.labelSmall?.copyWith( - fontSize: 14, - height: 22 / 14, - letterSpacing: - context.textTheme.titleSmall?.letterSpacing, - color: context.colors.onSurfaceVariant, - ), - ), - if (message.edited) ...[ - const SizedBox(width: Grid.half), + const SizedBox(width: Grid.xxs), Text( - '(edited)', + formatMessageTime(message.createdAt), style: context.textTheme.labelSmall?.copyWith( + fontSize: 14, + height: 22 / 14, + letterSpacing: context + .textTheme + .titleSmall + ?.letterSpacing, color: context.colors.onSurfaceVariant, - fontStyle: FontStyle.italic, ), ), + if (message.edited) ...[ + const SizedBox(width: Grid.half), + Text( + '(edited)', + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ], ], - ], + ), ), + MessageContent( + content: message.content, + mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, + channelNames: channelNames, + tags: message.tags, + baseStyle: context.textTheme.bodyLarge?.copyWith( + color: context.colors.onSurface, + ), + onChannelTap: (targetChannelId) { + openChannelLink( + context: context, + ref: ref, + channelId: targetChannelId, + currentChannelId: channelId, + ); + }, + onMentionTap: (pubkey) => + showUserProfileSheet(context, pubkey), ), - MessageContent( - content: message.content, - mentionNames: mentionNames, - agentMentionPubkeys: agentMentionPubkeys, - channelNames: channelNames, - tags: message.tags, - baseStyle: context.textTheme.bodyLarge?.copyWith( - color: context.colors.onSurface, - ), - onChannelTap: (targetChannelId) { - openChannelLink( - context: context, - ref: ref, - channelId: targetChannelId, - currentChannelId: channelId, - ); - }, - onMentionTap: (pubkey) => - showUserProfileSheet(context, pubkey), - ), - if (message.reactions.isNotEmpty) - ReactionRow( - reactions: message.reactions, - onToggle: (emoji) => - toggleReaction(ref, message, emoji), - ), - ], + if (message.reactions.isNotEmpty) + ReactionRow( + reactions: message.reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + ), + ], + ), ), ), - ), - ], + ], + ), ), ), ); diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index 789c020fa..fba440cc9 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -5,6 +5,7 @@ import 'package:buzz/features/activity/activity_provider.dart'; import 'package:buzz/features/activity/feed_item.dart'; import 'package:buzz/features/activity/reminders_provider.dart'; import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channel_detail_page.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/read_state/read_state_provider.dart'; import 'package:buzz/features/profile/user_cache_provider.dart'; @@ -292,6 +293,44 @@ void main() { expect(find.text('Nothing needs your action'), findsOneWidget); }); + testWidgets('opens a thread mention at the referenced message', ( + tester, + ) async { + final threadMention = FeedItem( + id: 'reply-event', + kind: 9, + pubkey: 'alice_pk', + content: 'Reply in a thread', + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + channelId: 'ch1', + channelName: 'general', + tags: const [ + ['e', 'thread-root', '', 'root'], + ['e', 'parent-reply', '', 'reply'], + ], + category: 'mention', + ); + final feed = HomeFeedResponse( + mentions: [threadMention], + needsAction: const [], + activity: const [], + agentActivity: const [], + ); + + await tester.pumpWidget(await buildTestable(feed: feed)); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('inbox-row-reply-event'))); + await tester.pumpAndSettle(); + + final page = tester.widget( + find.byType(ChannelDetailPage), + ); + expect(page.channel.id, 'ch1'); + expect(page.initialThreadRootId, 'parent-reply'); + expect(page.initialMessageId, 'reply-event'); + }); + testWidgets('thread filter matches grouped thread replies', (tester) async { await tester.pumpWidget(await buildTestable()); await tester.pumpAndSettle(); @@ -423,7 +462,11 @@ class _FakeReadStateNotifier extends ReadStateNotifier { ); @override - void markContextRead(String contextId, int unixTimestamp) { + void markContextRead( + String contextId, + int unixTimestamp, { + bool clearForcedMessages = false, + }) { state = state.copyWithContext(contextId, unixTimestamp); } } diff --git a/mobile/test/features/activity/feed_item_test.dart b/mobile/test/features/activity/feed_item_test.dart index db2952050..f655bf316 100644 --- a/mobile/test/features/activity/feed_item_test.dart +++ b/mobile/test/features/activity/feed_item_test.dart @@ -50,6 +50,48 @@ void main() { }); }); + group('FeedItem.threadHeadId', () { + FeedItem makeItem(List> tags) => FeedItem( + id: 'reply', + kind: 9, + pubkey: 'pk', + content: '', + createdAt: 0, + channelId: 'channel', + channelName: '', + tags: tags, + category: 'mention', + ); + + test('uses the reply marker for a direct thread reply', () { + expect( + makeItem(const [ + ['e', 'root', '', 'reply'], + ]).threadHeadId, + 'root', + ); + }); + + test('uses the direct parent marker for a nested thread reply', () { + expect( + makeItem(const [ + ['e', 'root', '', 'root'], + ['e', 'parent', '', 'reply'], + ]).threadHeadId, + 'parent', + ); + }); + + test('does not treat unrelated event references as threads', () { + expect( + makeItem(const [ + ['e', 'linked-event'], + ]).threadHeadId, + isNull, + ); + }); + }); + group('FeedItem.headline', () { FeedItem makeItem({required int kind, String category = 'activity'}) => FeedItem( diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index a013b4189..93efce5c7 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -148,7 +148,10 @@ Widget _buildTestable({ ReadStateNotifier? readStateNotifier, _FakeMessagesNotifier? messagesNotifier, String? canvasContent, - List? threadReplies, + String? initialMessageId, + String? initialThreadRootId, + Map> threadReplies = const {}, + TextScaler textScaler = TextScaler.noScaling, }) { final resolvedChannel = channel ?? _testChannel; final fakeChannelsNotifier = @@ -183,10 +186,10 @@ Widget _buildTestable({ channelActionsProvider.overrideWith(createChannelActions), if (readStateNotifier != null) readStateProvider.overrideWith(() => readStateNotifier), - if (threadReplies != null) + for (final entry in threadReplies.entries) threadRepliesProvider( - const ThreadRepliesArgs(channelId: _channelId, rootId: 'thread-root'), - ).overrideWith((ref) async => threadReplies), + ThreadRepliesArgs(channelId: _channelId, rootId: entry.key), + ).overrideWith((ref) async => entry.value), // Stub the relay client provider so preloadMembers doesn't crash. relayClientProvider.overrideWithValue( RelayClient(baseUrl: 'http://localhost:3000'), @@ -197,7 +200,16 @@ Widget _buildTestable({ child: MaterialApp( theme: AppTheme.light(), navigatorObservers: navigatorObservers, - home: ChannelDetailPage(channel: resolvedChannel), + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: textScaler), + child: ChannelDetailPage( + channel: resolvedChannel, + initialMessageId: initialMessageId, + initialThreadRootId: initialThreadRootId, + ), + ), + ), ), ); } @@ -1422,6 +1434,69 @@ void main() { }); }); + group('Deep-link navigation', () { + testWidgets('opens a nested reply in its direct-parent thread', ( + tester, + ) async { + final root = _textMsg( + id: 'root', + pubkey: 'alice', + content: 'Outer root', + createdAt: 1000, + ); + final parent = _textMsg( + id: 'parent', + pubkey: 'bob', + content: 'Nested thread head', + createdAt: 1100, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + final target = _textMsg( + id: 'target', + pubkey: 'carol', + content: 'Deeply nested target', + createdAt: 1200, + extraTags: const [ + ['e', 'root', '', 'root'], + ['e', 'parent', '', 'reply'], + ], + ); + + await tester.pumpWidget( + _buildTestable( + messages: [root, parent, target], + initialMessageId: 'target', + initialThreadRootId: 'parent', + threadReplies: { + // Relay subtree filtering is keyed by thread_metadata.root_event_id, + // so nested replies are returned by the outer-root query. + 'root': [parent, target], + }, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + 'carol': UserProfile(pubkey: 'carol', displayName: 'Carol'), + }, + ), + ); + await tester.pumpAndSettle(); + + final threadPage = tester.widget( + find.byType(ThreadDetailPage), + ); + expect(threadPage.threadHead.id, 'parent'); + expect(threadPage.initialMessageId, 'target'); + + final highlighted = tester.widget( + find.byKey(const ValueKey('thread-message-target')), + ); + final decoration = highlighted.decoration as BoxDecoration; + expect(decoration.color, isNot(Colors.transparent)); + }); + }); + group('Channel links', () { testWidgets('tapping a channel link opens that channel', (tester) async { final randomChannel = _channel(id: 'random-channel', name: 'random'); @@ -1576,7 +1651,7 @@ void main() { await tester.pumpWidget( _buildTestable( messages: [rootEvent], - threadReplies: replies, + threadReplies: {'thread-root': replies}, users: { 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'),