Files
buzz/mobile/test/features/activity/feed_item_test.dart
32ead93101 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>
2026-07-27 10:50:55 -07:00

304 lines
7.9 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/features/activity/feed_item.dart';
void main() {
group('FeedItem.fromJson', () {
test('parses all fields', () {
final item = FeedItem.fromJson({
'id': 'evt1',
'kind': 9,
'pubkey': 'abc123',
'content': 'Hello world',
'created_at': 1700000000,
'channel_id': 'ch1',
'channel_name': 'general',
'tags': [
['p', 'abc123'],
],
'category': 'mention',
});
expect(item.id, 'evt1');
expect(item.kind, 9);
expect(item.pubkey, 'abc123');
expect(item.content, 'Hello world');
expect(item.createdAt, 1700000000);
expect(item.channelId, 'ch1');
expect(item.channelName, 'general');
expect(item.tags, [
['p', 'abc123'],
]);
expect(item.category, 'mention');
});
test('handles null channel_id', () {
final item = FeedItem.fromJson({
'id': 'evt2',
'kind': 9,
'pubkey': 'abc',
'content': '',
'created_at': 0,
'channel_id': null,
'channel_name': null,
'tags': null,
'category': 'activity',
});
expect(item.channelId, isNull);
expect(item.channelName, '');
expect(item.tags, isEmpty);
});
});
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(
id: 'x',
kind: kind,
pubkey: 'pk',
content: '',
createdAt: 0,
channelId: null,
channelName: '',
tags: const [],
category: category,
);
test('returns known kind labels', () {
expect(makeItem(kind: 45001).headline, 'Forum post');
expect(makeItem(kind: 45003).headline, 'Forum reply');
expect(makeItem(kind: 46010).headline, 'Approval requested');
expect(makeItem(kind: 43001).headline, 'Job requested');
expect(makeItem(kind: 43002).headline, 'Job accepted');
expect(makeItem(kind: 43003).headline, 'Progress update');
expect(makeItem(kind: 43004).headline, 'Job result');
expect(makeItem(kind: 43005).headline, 'Job cancelled');
expect(makeItem(kind: 43006).headline, 'Job failed');
});
test('falls back to category for unknown kinds', () {
expect(makeItem(kind: 9, category: 'mention').headline, 'Mention');
expect(
makeItem(kind: 9, category: 'agent_activity').headline,
'Agent update',
);
expect(
makeItem(kind: 9, category: 'activity').headline,
'Channel update',
);
});
});
group('FeedItem.displayContent', () {
FeedItem makeItem({required String content, int kind = 9}) => FeedItem(
id: 'x',
kind: kind,
pubkey: 'pk',
content: content,
createdAt: 0,
channelId: null,
channelName: '',
tags: const [],
category: 'activity',
);
test('returns trimmed content when non-empty', () {
expect(makeItem(content: ' Hello ').displayContent, 'Hello');
});
test('returns approval fallback for empty approval events', () {
expect(
makeItem(content: '', kind: 46010).displayContent,
'A workflow is waiting for approval.',
);
});
test('returns generic fallback for other empty events', () {
expect(makeItem(content: '').displayContent, 'No additional details.');
expect(makeItem(content: ' ').displayContent, 'No additional details.');
});
});
group('HomeFeedResponse', () {
test('parses from JSON with all four categories', () {
final response = HomeFeedResponse.fromJson({
'feed': {
'mentions': [
{
'id': 'm1',
'kind': 9,
'pubkey': 'pk',
'content': 'hi',
'created_at': 100,
'channel_id': 'c1',
'channel_name': 'general',
'tags': [],
'category': 'mention',
},
],
'needs_action': [],
'activity': [
{
'id': 'a1',
'kind': 9,
'pubkey': 'pk',
'content': 'update',
'created_at': 200,
'channel_id': 'c2',
'channel_name': 'dev',
'tags': [],
'category': 'activity',
},
],
'agent_activity': [],
},
'meta': {'since': 0, 'total': 2, 'generated_at': 300},
});
expect(response.mentions.length, 1);
expect(response.needsAction, isEmpty);
expect(response.activity.length, 1);
expect(response.agentActivity, isEmpty);
});
test('handles null category lists gracefully', () {
final response = HomeFeedResponse.fromJson({
'feed': {
'mentions': null,
'needs_action': null,
'activity': null,
'agent_activity': null,
},
});
expect(response.mentions, isEmpty);
expect(response.isEmpty, isTrue);
});
test('all merges and sorts newest-first', () {
final response = HomeFeedResponse(
mentions: [
FeedItem(
id: 'old',
kind: 9,
pubkey: 'pk',
content: '',
createdAt: 100,
channelId: null,
channelName: '',
tags: const [],
category: 'mention',
),
],
needsAction: const [],
activity: [
FeedItem(
id: 'new',
kind: 9,
pubkey: 'pk',
content: '',
createdAt: 300,
channelId: null,
channelName: '',
tags: const [],
category: 'activity',
),
],
agentActivity: [
FeedItem(
id: 'mid',
kind: 9,
pubkey: 'pk',
content: '',
createdAt: 200,
channelId: null,
channelName: '',
tags: const [],
category: 'agent_activity',
),
],
);
final all = response.all;
expect(all.length, 3);
expect(all[0].id, 'new');
expect(all[1].id, 'mid');
expect(all[2].id, 'old');
});
test('isEmpty returns true when all lists empty', () {
final response = HomeFeedResponse(
mentions: [],
needsAction: [],
activity: [],
agentActivity: [],
);
expect(response.isEmpty, isTrue);
});
test('isEmpty returns false when any list has items', () {
final response = HomeFeedResponse(
mentions: [
FeedItem(
id: 'x',
kind: 9,
pubkey: 'pk',
content: '',
createdAt: 0,
channelId: null,
channelName: '',
tags: const [],
category: 'mention',
),
],
needsAction: const [],
activity: const [],
agentActivity: const [],
);
expect(response.isEmpty, isFalse);
});
});
}