mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu
npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp
npub15w828kxsxu2684ynste0uah2jwkgatd99flt7ds4523hzm8ju6cshdr8hh
parent
e28707f6b2
commit
32ead93101
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user