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 <a38ea3d8d03715a3d49382f2fe76ea93ac8eada52a7ebf3615a2a3716cf2e6b1@buzz.block.builderlab.xyz>
Co-authored-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz>
Co-authored-by: npub15w828kxsxu2684ynste0uah2jwkgatd99flt7ds4523hzm8ju6cshdr8hh <a38ea3d8d03715a3d49382f2fe76ea93ac8eada52a7ebf3615a2a3716cf2e6b1@buzz.block.builderlab.xyz>
This commit is contained in:
Tom Brow
2026-07-27 10:50:55 -07:00
committed by GitHub
co-authored by npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp npub15w828kxsxu2684ynste0uah2jwkgatd99flt7ds4523hzm8ju6cshdr8hh
parent e28707f6b2
commit 32ead93101
6 changed files with 284 additions and 89 deletions
@@ -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<void>(
@@ -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) {
@@ -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<TimelineMessage>? 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),
),
],
),
),
),
),
],
],
),
),
),
);
@@ -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<ChannelDetailPage>(
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);
}
}
@@ -50,6 +50,48 @@ void main() {
});
});
group('FeedItem.threadHeadId', () {
FeedItem makeItem(List<List<String>> 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(
@@ -148,7 +148,10 @@ Widget _buildTestable({
ReadStateNotifier? readStateNotifier,
_FakeMessagesNotifier? messagesNotifier,
String? canvasContent,
List<NostrEvent>? threadReplies,
String? initialMessageId,
String? initialThreadRootId,
Map<String, List<NostrEvent>> 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<ThreadDetailPage>(
find.byType(ThreadDetailPage),
);
expect(threadPage.threadHead.id, 'parent');
expect(threadPage.initialMessageId, 'target');
final highlighted = tester.widget<DecoratedBox>(
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'),