import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:buzz/features/channels/channel_window.dart'; import 'package:buzz/features/channels/timeline_message.dart'; import 'package:buzz/shared/relay/relay.dart'; NostrEvent _textMsg({ required String id, String pubkey = 'alice', String content = 'hello', int createdAt = 1000, List>? extraTags, }) => NostrEvent( id: id, pubkey: pubkey, createdAt: createdAt, kind: EventKind.streamMessage, tags: [ ['h', 'ch1'], ...?extraTags, ], content: content, sig: '', ); /// A reply message with proper root/reply e-tag markers. NostrEvent _replyMsg({ required String id, required String parentId, String? rootId, String pubkey = 'alice', String content = 'reply', int createdAt = 2000, List> extraTags = const [], }) { final root = rootId ?? parentId; final eTags = parentId == root ? [ ['e', root, '', 'reply'], ] : [ ['e', root, '', 'root'], ['e', parentId, '', 'reply'], ]; return _textMsg( id: id, pubkey: pubkey, content: content, createdAt: createdAt, extraTags: [...eTags, ...extraTags], ); } NostrEvent _systemMsg({ required String id, required Map payload, int createdAt = 1000, }) => NostrEvent( id: id, pubkey: 'relay', createdAt: createdAt, kind: EventKind.systemMessage, tags: [ ['h', 'ch1'], ], content: jsonEncode(payload), sig: '', ); NostrEvent _deletion({ required String id, required List targets, int createdAt = 2000, }) => NostrEvent( id: id, pubkey: 'alice', createdAt: createdAt, kind: EventKind.deletion, tags: [ ['h', 'ch1'], for (final t in targets) ['e', t], ], content: '', sig: '', ); NostrEvent _nip29Deletion({ required String id, required List targets, int createdAt = 2000, }) => NostrEvent( id: id, pubkey: 'alice', createdAt: createdAt, kind: EventKind.nip29DeleteEvent, tags: [ ['h', 'ch1'], for (final t in targets) ['e', t], ], content: '', sig: '', ); NostrEvent _edit({ required String id, required String targetId, required String content, int createdAt = 2000, List> extraTags = const [], }) => NostrEvent( id: id, pubkey: 'alice', createdAt: createdAt, kind: EventKind.streamMessageEdit, tags: [ ['h', 'ch1'], ['e', targetId], ...extraTags, ], content: content, sig: '', ); NostrEvent _reaction({ required String id, required String targetId, int createdAt = 2000, String content = '👍', List> extraTags = const [], }) => NostrEvent( id: id, pubkey: 'bob', createdAt: createdAt, kind: EventKind.reaction, tags: [ ['h', 'ch1'], ['e', targetId], ...extraTags, ], content: content, sig: '', ); NostrEvent _huddleEvent({ required String id, required int kind, String pubkey = 'pk1', int createdAt = 1000, }) => NostrEvent( id: id, pubkey: pubkey, createdAt: createdAt, kind: kind, tags: [ ['h', 'ch1'], ], content: jsonEncode({ 'ephemeral_channel_id': '8d764100-fd8f-44cf-9c98-6d8fbd739b8c', }), sig: '', ); void main() { group('SystemEvent.fromContent', () { test('parses all known event types', () { final types = { 'member_joined': SystemEventType.memberJoined, 'member_left': SystemEventType.memberLeft, 'member_removed': SystemEventType.memberRemoved, 'topic_changed': SystemEventType.topicChanged, 'purpose_changed': SystemEventType.purposeChanged, 'channel_created': SystemEventType.channelCreated, 'channel_archived': SystemEventType.channelArchived, 'channel_unarchived': SystemEventType.channelUnarchived, }; for (final entry in types.entries) { final event = SystemEvent.fromContent( jsonEncode({'type': entry.key, 'actor': 'pk1'}), ); expect(event, isNotNull, reason: 'Failed for ${entry.key}'); expect(event!.type, entry.value); expect(event.actorPubkey, 'pk1'); } }); test('returns null for unknown type', () { final event = SystemEvent.fromContent( jsonEncode({'type': 'unknown_type'}), ); expect(event, isNull); }); test('returns null for invalid JSON', () { expect(SystemEvent.fromContent('not json'), isNull); }); test('returns null for wrong JSON field types', () { expect( SystemEvent.fromContent( jsonEncode({ 'type': ['member_joined'], 'actor': 123, }), ), isNull, ); }); test('parses target, topic, and purpose fields', () { final event = SystemEvent.fromContent( jsonEncode({ 'type': 'topic_changed', 'actor': 'pk1', 'target': 'pk2', 'topic': 'New topic', 'purpose': 'New purpose', }), ); expect(event, isNotNull); expect(event!.targetPubkey, 'pk2'); expect(event.topic, 'New topic'); expect(event.purpose, 'New purpose'); }); }); group('SystemEvent.describe', () { String resolve(String? pk) => pk == 'pk1' ? 'Alice' : 'Bob'; test('member_joined self', () { final event = SystemEvent( type: SystemEventType.memberJoined, actorPubkey: 'pk1', targetPubkey: 'pk1', ); expect(event.describe(resolve), 'Alice joined the channel'); }); test('member_joined by other', () { final event = SystemEvent( type: SystemEventType.memberJoined, actorPubkey: 'pk1', targetPubkey: 'pk2', ); expect(event.describe(resolve), 'Bob was added by Alice'); }); test('member_left', () { final event = SystemEvent( type: SystemEventType.memberLeft, actorPubkey: 'pk1', ); expect(event.describe(resolve), 'Alice left the channel'); }); test('member_removed', () { final event = SystemEvent( type: SystemEventType.memberRemoved, actorPubkey: 'pk1', targetPubkey: 'pk2', ); expect(event.describe(resolve), 'Alice removed Bob from the channel'); }); test('topic_changed', () { final event = SystemEvent( type: SystemEventType.topicChanged, actorPubkey: 'pk1', topic: 'Release v2', ); expect( event.describe(resolve), 'Alice changed the topic to "Release v2"', ); }); test('purpose_changed', () { final event = SystemEvent( type: SystemEventType.purposeChanged, actorPubkey: 'pk1', purpose: 'Daily standups', ); expect( event.describe(resolve), 'Alice changed the purpose to "Daily standups"', ); }); // The relay reports a clear as a change carrying an empty string, so // without the cleared branch this reads: changed the topic to "". test('a blank topic or purpose reads as cleared', () { for (final blank in [null, '', ' \n\t ']) { expect( SystemEvent( type: SystemEventType.topicChanged, actorPubkey: 'pk1', topic: blank, ).describe(resolve), 'Alice cleared the topic', ); expect( SystemEvent( type: SystemEventType.purposeChanged, actorPubkey: 'pk1', purpose: blank, ).describe(resolve), 'Alice cleared the purpose', ); } }); test('no caption announces empty quotes', () { for (final value in [null, '', ' ', 'Real topic']) { expect( SystemEvent( type: SystemEventType.topicChanged, actorPubkey: 'pk1', topic: value, ).describe(resolve), isNot(contains('""')), ); } }); test('surrounding whitespace is trimmed out of the quotes', () { expect( SystemEvent( type: SystemEventType.topicChanged, actorPubkey: 'pk1', topic: ' Release v2 ', ).describe(resolve), 'Alice changed the topic to "Release v2"', ); }); test('channel_created', () { final event = SystemEvent( type: SystemEventType.channelCreated, actorPubkey: 'pk1', ); expect(event.describe(resolve), 'Alice created this channel'); }); test('channel_archived', () { final event = SystemEvent( type: SystemEventType.channelArchived, actorPubkey: 'pk1', ); expect(event.describe(resolve), 'Alice archived this channel'); }); test('channel_unarchived', () { final event = SystemEvent( type: SystemEventType.channelUnarchived, actorPubkey: 'pk1', ); expect(event.describe(resolve), 'Alice unarchived this channel'); }); test('huddle_started', () { final event = SystemEvent( type: SystemEventType.huddleStarted, actorPubkey: 'pk1', ); expect(event.describe(resolve), 'Alice started a huddle'); }); test('huddle_ended', () { final event = SystemEvent( type: SystemEventType.huddleEnded, actorPubkey: 'pk1', ); expect(event.describe(resolve), 'Alice ended the huddle'); }); }); group('formatTimeline', () { test('channel filters include desktop huddle lifecycle event kinds', () { expect( EventKind.channelEventKinds, containsAll([ EventKind.huddleStarted, EventKind.huddleParticipantJoined, EventKind.huddleParticipantLeft, EventKind.huddleEnded, ]), ); }); test('passes through text messages', () { final events = [ _textMsg(id: 'a', content: 'hello'), _textMsg(id: 'b', content: 'world', createdAt: 1100), ]; final result = formatTimeline(events); expect(result, hasLength(2)); expect(result[0].content, 'hello'); expect(result[1].content, 'world'); expect(result[0].isSystem, false); expect(result[0].edited, false); }); test('filters deleted messages', () { final events = [ _textMsg(id: 'a', content: 'keep'), _textMsg(id: 'b', content: 'delete', createdAt: 1100), _deletion(id: 'd1', targets: ['b']), ]; final result = formatTimeline(events); expect(result, hasLength(1)); expect(result[0].content, 'keep'); }); test('filters messages deleted via kind:9005 (Buzz-native)', () { // Agents emit kind:9005 deletes via the CLI. Mobile must mirror desktop // and treat 9005 as a deletion marker, otherwise agent-deleted messages // stay rendered until manual refresh. final events = [ _textMsg(id: 'a', content: 'keep'), _textMsg(id: 'b', content: 'delete', createdAt: 1100), _nip29Deletion(id: 'd1', targets: ['b']), ]; final result = formatTimeline(events); expect(result, hasLength(1)); expect(result[0].content, 'keep'); }); test('applies edits', () { final events = [ _textMsg(id: 'a', content: 'original'), _edit(id: 'e1', targetId: 'a', content: 'edited'), ]; final result = formatTimeline(events); expect(result, hasLength(1)); expect(result[0].content, 'edited'); expect(result[0].edited, true); }); test('applies edit tags for custom emoji rendering', () { final events = [ _textMsg( id: 'a', content: 'original :old:', extraTags: [ ['emoji', 'old', 'https://relay.example/old.png'], ], ), _edit( id: 'e1', targetId: 'a', content: 'edited :new:', extraTags: [ ['emoji', 'new', 'https://relay.example/new.png'], ], ), ]; final result = formatTimeline(events); expect(result.single.content, 'edited :new:'); expect( result.single.tags.any( (tag) => tag.length >= 3 && tag[0] == 'emoji' && tag[1] == 'new' && tag[2] == 'https://relay.example/new.png', ), isTrue, ); expect( result.single.tags.any( (tag) => tag.length >= 3 && tag[0] == 'emoji' && tag[1] == 'old' && tag[2] == 'https://relay.example/old.png', ), isFalse, ); }); test('preserves custom emoji reaction image URL from emoji tag', () { final messages = formatTimeline([ _textMsg(id: 'm1'), _reaction( id: 'r1', targetId: 'm1', content: ':shipit:', extraTags: [ ['emoji', 'shipit', 'https://relay.example/shipit.png'], ], ), ], currentPubkey: 'bob'); final reaction = messages.single.reactions.single; expect(reaction.emoji, ':shipit:'); expect(reaction.emojiUrl, 'https://relay.example/shipit.png'); expect(reaction.reactedByCurrentUser, isTrue); }); test('latest edit wins', () { final events = [ _textMsg(id: 'a', content: 'v1'), _edit(id: 'e1', targetId: 'a', content: 'v2', createdAt: 2000), _edit(id: 'e2', targetId: 'a', content: 'v3', createdAt: 3000), ]; final result = formatTimeline(events); expect(result[0].content, 'v3'); }); test('deleted edit is ignored', () { final events = [ _textMsg(id: 'a', content: 'original'), _edit(id: 'e1', targetId: 'a', content: 'edited'), _deletion(id: 'd1', targets: ['e1']), ]; final result = formatTimeline(events); expect(result[0].content, 'original'); expect(result[0].edited, false); }); test('edit of deleted message is ignored', () { final events = [ _textMsg(id: 'a', content: 'original'), _edit(id: 'e1', targetId: 'a', content: 'edited'), _deletion(id: 'd1', targets: ['a']), ]; final result = formatTimeline(events); expect(result, isEmpty); }); test('system messages are parsed', () { final events = [ _systemMsg( id: 's1', payload: {'type': 'channel_created', 'actor': 'pk1'}, ), ]; final result = formatTimeline(events); expect(result, hasLength(1)); expect(result[0].isSystem, true); expect(result[0].systemEvent, isNotNull); expect(result[0].systemEvent!.type, SystemEventType.channelCreated); }); test('huddle start and end events render as system rows', () { final result = formatTimeline([ _huddleEvent(id: 'h1', kind: EventKind.huddleStarted), _huddleEvent(id: 'h2', kind: EventKind.huddleEnded, createdAt: 1100), ]); expect(result, hasLength(2)); expect(result[0].isSystem, isTrue); expect(result[0].systemEvent!.type, SystemEventType.huddleStarted); expect(result[1].isSystem, isTrue); expect(result[1].systemEvent!.type, SystemEventType.huddleEnded); }); test('huddle participant events are lifecycle metadata only', () { final result = formatTimeline([ _huddleEvent(id: 'h1', kind: EventKind.huddleParticipantJoined), _huddleEvent(id: 'h2', kind: EventKind.huddleParticipantLeft), ]); expect(result, isEmpty); }); test('unknown system messages are dropped', () { final events = [ _systemMsg(id: 's1', payload: {'type': 'unknown'}), ]; final result = formatTimeline(events); expect(result, isEmpty); }); test('malformed system messages are skipped safely', () { final events = [ _systemMsg( id: 's1', payload: { 'type': ['member_joined'], 'actor': 123, }, ), _textMsg(id: 'a', content: 'hello', createdAt: 1100), ]; final result = formatTimeline(events); expect(result, hasLength(1)); expect(result[0].content, 'hello'); }); test('reactions and typing indicators are filtered out', () { final events = [ _textMsg(id: 'a', content: 'hello'), _reaction(id: 'r1', targetId: 'a'), NostrEvent( id: 'typing1', pubkey: 'bob', createdAt: 1000, kind: EventKind.typingIndicator, tags: [ ['h', 'ch1'], ], content: '', sig: '', ), ]; final result = formatTimeline(events); expect(result, hasLength(1)); expect(result[0].content, 'hello'); }); test('preserves chronological order', () { final events = [ _textMsg(id: 'a', content: 'first', createdAt: 1000), _systemMsg( id: 's1', payload: {'type': 'member_joined', 'actor': 'pk1', 'target': 'pk1'}, createdAt: 1100, ), _textMsg(id: 'b', content: 'second', createdAt: 1200), ]; final result = formatTimeline(events); expect(result, hasLength(3)); expect(result[0].content, 'first'); expect(result[1].isSystem, true); expect(result[2].content, 'second'); }); test('streamMessageV2 events are included', () { final events = [ NostrEvent( id: 'v2msg', pubkey: 'alice', createdAt: 1000, kind: EventKind.streamMessageV2, tags: [ ['h', 'ch1'], ], content: 'legacy message', sig: '', ), ]; final result = formatTimeline(events); expect(result, hasLength(1)); expect(result[0].content, 'legacy message'); }); test('streamMessageDiff events are included', () { final events = [ NostrEvent( id: 'diff1', pubkey: 'alice', createdAt: 1000, kind: EventKind.streamMessageDiff, tags: [ ['h', 'ch1'], ], content: '```diff\n-old\n+new\n```', sig: '', ), ]; final result = formatTimeline(events); expect(result, hasLength(1)); expect(result[0].content, contains('diff')); }); }); group('NostrEvent.threadReference', () { test('returns nulls for top-level message (no e-tags)', () { final event = _textMsg(id: 'a'); final ref = event.threadReference; expect(ref.parentId, isNull); expect(ref.rootId, isNull); }); test('returns nulls for e-tags without root/reply markers', () { final event = _textMsg( id: 'a', extraTags: [ ['e', 'target'], ], ); final ref = event.threadReference; expect(ref.parentId, isNull); expect(ref.rootId, isNull); }); test('parses direct reply to root (single reply marker)', () { final event = _textMsg( id: 'reply1', extraTags: [ ['e', 'root1', '', 'reply'], ], ); final ref = event.threadReference; expect(ref.parentId, 'root1'); expect(ref.rootId, 'root1'); }); test('parses nested reply (root + reply markers)', () { final event = _textMsg( id: 'reply2', extraTags: [ ['e', 'root1', '', 'root'], ['e', 'parent1', '', 'reply'], ], ); final ref = event.threadReference; expect(ref.parentId, 'parent1'); expect(ref.rootId, 'root1'); }); test('last reply tag wins when multiple exist', () { final event = _textMsg( id: 'reply3', extraTags: [ ['e', 'root1', '', 'root'], ['e', 'old_parent', '', 'reply'], ['e', 'new_parent', '', 'reply'], ], ); final ref = event.threadReference; expect(ref.parentId, 'new_parent'); expect(ref.rootId, 'root1'); }); }); group('formatTimeline threading', () { test('root messages have null parentId and rootId', () { final events = [_textMsg(id: 'a')]; final result = formatTimeline(events); expect(result[0].parentId, isNull); expect(result[0].rootId, isNull); }); test('reply messages carry parentId and rootId', () { final events = [ _textMsg(id: 'root1'), _replyMsg(id: 'r1', parentId: 'root1', createdAt: 2000), ]; final result = formatTimeline(events); final reply = result.firstWhere((m) => m.id == 'r1'); expect(reply.parentId, 'root1'); expect(reply.rootId, 'root1'); }); test('nested reply has distinct parentId and rootId', () { final events = [ _textMsg(id: 'root1'), _replyMsg(id: 'r1', parentId: 'root1', createdAt: 2000), _replyMsg(id: 'r2', parentId: 'r1', rootId: 'root1', createdAt: 3000), ]; final result = formatTimeline(events); final nested = result.firstWhere((m) => m.id == 'r2'); expect(nested.parentId, 'r1'); expect(nested.rootId, 'root1'); }); }); group('buildMainTimelineEntries', () { test('returns only root messages', () { final messages = formatTimeline([ _textMsg(id: 'a', createdAt: 1000), _replyMsg(id: 'r1', parentId: 'a', createdAt: 2000), _textMsg(id: 'b', createdAt: 3000), ]); final entries = buildMainTimelineEntries(messages); expect(entries, hasLength(2)); expect(entries[0].message.id, 'a'); expect(entries[1].message.id, 'b'); }); test('includes broadcast replies in the main timeline', () { final messages = formatTimeline([ _textMsg(id: 'a', createdAt: 1000), _replyMsg(id: 'hidden', parentId: 'a', createdAt: 2000), _replyMsg( id: 'broadcast', parentId: 'a', createdAt: 3000, extraTags: const [ ['broadcast', '1'], ], ), ]); final entries = buildMainTimelineEntries(messages); expect(entries.map((entry) => entry.message.id), ['a', 'broadcast']); }); test('root without replies has null summary', () { final messages = formatTimeline([_textMsg(id: 'a')]); final entries = buildMainTimelineEntries(messages); expect(entries[0].summary, isNull); }); test('root with replies has summary with correct count', () { final messages = formatTimeline([ _textMsg(id: 'a', createdAt: 1000), _replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000), _replyMsg(id: 'r2', parentId: 'a', pubkey: 'carol', createdAt: 3000), ]); final entries = buildMainTimelineEntries(messages); expect(entries, hasLength(1)); expect(entries[0].summary, isNotNull); expect(entries[0].summary!.replyCount, 2); expect(entries[0].summary!.threadHeadId, 'a'); expect(entries[0].summary!.lastReplyAt, 3000); }); test( 'summary counts every loaded descendant, not only direct children', () { final messages = formatTimeline([ _textMsg(id: 'a', createdAt: 1000), _replyMsg(id: 'r1', parentId: 'a', createdAt: 2000), _replyMsg(id: 'r2', parentId: 'r1', rootId: 'a', createdAt: 3000), ]); final entries = buildMainTimelineEntries(messages); // The root badge stands for the whole thread, so the reply to r1 counts // towards 'a' as well. This matches the relay's `descendant_count` and // the desktop's `buildDescendantStatsByMessageId`. expect(entries[0].summary!.replyCount, 2); expect(entries[0].summary!.lastReplyAt, 3000); }, ); test('summary has up to 3 unique participant pubkeys', () { final messages = formatTimeline([ _textMsg(id: 'a', createdAt: 1000), _replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000), _replyMsg(id: 'r2', parentId: 'a', pubkey: 'carol', createdAt: 3000), _replyMsg(id: 'r3', parentId: 'a', pubkey: 'bob', createdAt: 4000), _replyMsg(id: 'r4', parentId: 'a', pubkey: 'dave', createdAt: 5000), _replyMsg(id: 'r5', parentId: 'a', pubkey: 'eve', createdAt: 6000), ]); final entries = buildMainTimelineEntries(messages); final participants = entries[0].summary!.participantPubkeys; expect(participants, hasLength(3)); // Most recent unique: eve, dave, bob (r5, r4, r3 — carol skipped // because bob at r3 comes before carol at r2 when walking backwards). // Reversed to chronological order. expect(participants, ['bob', 'dave', 'eve']); }); test('system messages remain in entries (parentId is null)', () { final events = [ _systemMsg( id: 's1', payload: {'type': 'channel_created', 'actor': 'pk1'}, createdAt: 500, ), _textMsg(id: 'a', createdAt: 1000), ]; final messages = formatTimeline(events); final entries = buildMainTimelineEntries(messages); expect(entries, hasLength(2)); expect(entries[0].message.isSystem, true); }); test('empty input returns empty', () { expect(buildMainTimelineEntries([]), isEmpty); }); }); group('buildMainTimelineEntries relay summary merge', () { ChannelWindowThreadSummary relaySummary({ required int replyCount, int? descendantCount, int? lastReplyAt, List participantPubkeys = const ['zoe'], }) => ChannelWindowThreadSummary( replyCount: replyCount, descendantCount: descendantCount ?? replyCount, lastReplyAt: lastReplyAt, participantPubkeys: participantPubkeys, ); test('relay recount covers replies this client never loaded', () { final messages = formatTimeline([_textMsg(id: 'a', createdAt: 1000)]); final entries = buildMainTimelineEntries( messages, relaySummaries: {'a': relaySummary(replyCount: 4, lastReplyAt: 9000)}, ); expect(entries.single.summary!.replyCount, 4); expect(entries.single.summary!.lastReplyAt, 9000); expect(entries.single.summary!.participantPubkeys, ['zoe']); }); test('a reply newer than the recount is added to the badge', () { final messages = formatTimeline([ _textMsg(id: 'a', createdAt: 1000), _replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000), _replyMsg(id: 'r2', parentId: 'a', pubkey: 'carol', createdAt: 3000), ]); // The relay counted only r1 before r2 landed. final entries = buildMainTimelineEntries( messages, relaySummaries: {'a': relaySummary(replyCount: 1, lastReplyAt: 2000)}, ); expect(entries.single.summary!.replyCount, 2); expect(entries.single.summary!.lastReplyAt, 3000); }); test('a reply in the same second as the recount still counts', () { final messages = formatTimeline([ _textMsg(id: 'a', createdAt: 1000), _replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000), _replyMsg(id: 'r2', parentId: 'a', pubkey: 'carol', createdAt: 2000), ]); // Relay timestamps have second precision, so an equal timestamp is no // proof the recount already included the second reply. final entries = buildMainTimelineEntries( messages, relaySummaries: {'a': relaySummary(replyCount: 1, lastReplyAt: 2000)}, ); expect(entries.single.summary!.replyCount, 2); }); test('a lost recount still leaves a badge from the local reply', () { final messages = formatTimeline([ _textMsg(id: 'a', createdAt: 1000), _replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000), ]); final entries = buildMainTimelineEntries(messages); expect(entries.single.summary!.replyCount, 1); expect(entries.single.summary!.participantPubkeys, ['bob']); }); test('the relay recount wins when it is ahead of the loaded replies', () { final messages = formatTimeline([ _textMsg(id: 'a', createdAt: 1000), _replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000), ]); final entries = buildMainTimelineEntries( messages, relaySummaries: {'a': relaySummary(replyCount: 7, lastReplyAt: 8000)}, ); expect(entries.single.summary!.replyCount, 7); expect(entries.single.summary!.lastReplyAt, 8000); }); test('a zero recount does not erase a locally seen reply', () { final messages = formatTimeline([ _textMsg(id: 'a', createdAt: 1000), _replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000), ]); final entries = buildMainTimelineEntries( messages, relaySummaries: {'a': relaySummary(replyCount: 0, lastReplyAt: null)}, ); expect(entries.single.summary!.replyCount, 1); expect(entries.single.summary!.lastReplyAt, 2000); }); test('a locally seen nested reply raises its root badge', () { final messages = formatTimeline([ _textMsg(id: 'a', createdAt: 1000), _replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000), _replyMsg( id: 'r2', parentId: 'r1', rootId: 'a', pubkey: 'carol', createdAt: 3000, ), ]); // The recount for 'a' predates r2, so only the locally seen nested reply // can bring the root badge, time, and facepile up to date. final entries = buildMainTimelineEntries( messages, relaySummaries: { 'a': relaySummary( replyCount: 1, lastReplyAt: 2000, participantPubkeys: const ['bob'], ), }, ); final root = entries.firstWhere((entry) => entry.message.id == 'a'); expect(root.summary!.replyCount, 2); expect(root.summary!.lastReplyAt, 3000); expect(root.summary!.participantPubkeys, ['bob', 'carol']); }); test('the relay half counts descendants, not direct replies', () { final messages = formatTimeline([_textMsg(id: 'a', createdAt: 1000)]); // The relay reports both numbers. A thread of nested replies has a // `reply_count` far below its `descendant_count`, and the badge stands // for the whole thread. final entries = buildMainTimelineEntries( messages, relaySummaries: { 'a': relaySummary( replyCount: 1, descendantCount: 5, lastReplyAt: 9000, ), }, ); expect(entries.single.summary!.replyCount, 5); }); test('a nested reply badges the reply it answers', () { final messages = formatTimeline([ _textMsg(id: 'a', createdAt: 1000), _replyMsg( id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000, extraTags: const [ ['broadcast', '1'], ], ), _replyMsg( id: 'r2', parentId: 'r1', rootId: 'a', pubkey: 'carol', createdAt: 3000, ), ]); // The relay keys recounts by the outermost root, so r1 never gets one and // its badge has to come from the locally seen nested reply. final entries = buildMainTimelineEntries( messages, relaySummaries: {'a': relaySummary(replyCount: 1, lastReplyAt: 2000)}, ); final byId = {for (final entry in entries) entry.message.id: entry}; expect(byId['a']!.summary!.replyCount, 2); expect(byId['r1']!.summary!.replyCount, 1); expect(byId['r1']!.summary!.participantPubkeys, ['carol']); expect(byId['r1']!.summary!.lastReplyAt, 3000); }); test('merged participants keep relay identities first, capped at 3', () { final messages = formatTimeline([ _textMsg(id: 'a', createdAt: 1000), _replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000), _replyMsg(id: 'r2', parentId: 'a', pubkey: 'carol', createdAt: 3000), ]); final entries = buildMainTimelineEntries( messages, relaySummaries: { 'a': relaySummary( replyCount: 1, lastReplyAt: 2000, participantPubkeys: const ['zoe', 'yara'], ), }, ); expect(entries.single.summary!.participantPubkeys, [ 'zoe', 'yara', 'carol', ]); }); test('a deleted reply does not hold the badge above the recount', () { // The relay counts down on delete and re-emits the recount, so taking the // larger count must not resurrect a deleted reply. `formatTimeline` drops // it from the local half first, which is what keeps `max` honest here. final messages = formatTimeline([ _textMsg(id: 'a', createdAt: 1000), _replyMsg(id: 'r1', parentId: 'a', pubkey: 'bob', createdAt: 2000), _replyMsg(id: 'r2', parentId: 'a', pubkey: 'carol', createdAt: 3000), _deletion(id: 'd1', targets: ['r2']), ]); final entries = buildMainTimelineEntries( messages, relaySummaries: {'a': relaySummary(replyCount: 1, lastReplyAt: 2000)}, ); expect(entries.single.summary!.replyCount, 1); expect(entries.single.summary!.lastReplyAt, 2000); }); test('a participant in both halves is not listed twice', () { final messages = formatTimeline([ _textMsg(id: 'a', createdAt: 1000), _replyMsg(id: 'r1', parentId: 'a', pubkey: 'BOB', createdAt: 2000), _replyMsg(id: 'r2', parentId: 'a', pubkey: 'carol', createdAt: 3000), ]); final entries = buildMainTimelineEntries( messages, relaySummaries: { 'a': relaySummary( replyCount: 2, lastReplyAt: 3000, participantPubkeys: const ['carol', 'bob'], ), }, ); expect(entries.single.summary!.participantPubkeys, ['carol', 'bob']); }); }); group('groupMembershipTimelineEntries', () { List entries(List events) => buildMainTimelineEntries(formatTimeline(events)); test('groups consecutive additions by one actor within five minutes', () { final grouped = groupMembershipTimelineEntries( entries([ _systemMsg( id: 'a', payload: { 'type': 'member_joined', 'actor': 'alice', 'target': 'bob', }, createdAt: 1000, ), _systemMsg( id: 'b', payload: { 'type': 'member_joined', 'actor': 'alice', 'target': 'carol', }, createdAt: 1060, ), _systemMsg( id: 'c', payload: { 'type': 'member_joined', 'actor': 'alice', 'target': 'dave', }, createdAt: 1300, ), ]), ); expect(grouped, hasLength(1)); expect(grouped.single.map((entry) => entry.message.id), ['a', 'b', 'c']); }); test('groups self-joins from different people', () { final grouped = groupMembershipTimelineEntries( entries([ _systemMsg( id: 'a', payload: { 'type': 'member_joined', 'actor': 'alice', 'target': 'alice', }, createdAt: 1000, ), _systemMsg( id: 'b', payload: {'type': 'member_joined', 'actor': 'bob', 'target': 'bob'}, createdAt: 1060, ), ]), ); expect(grouped.single, hasLength(2)); }); test('uses a fixed window anchored on the newest addition', () { final grouped = groupMembershipTimelineEntries( entries([ _systemMsg( id: 'a', payload: { 'type': 'member_joined', 'actor': 'alice', 'target': 'bob', }, createdAt: 1000, ), _systemMsg( id: 'b', payload: { 'type': 'member_joined', 'actor': 'alice', 'target': 'carol', }, createdAt: 1240, ), _systemMsg( id: 'c', payload: { 'type': 'member_joined', 'actor': 'alice', 'target': 'dave', }, createdAt: 1301, ), ]), ); expect(grouped.map((group) => group.length), [1, 2]); }); test('actor changes, messages, and day boundaries break groups', () { final dayOne = DateTime(2026, 7, 14, 23, 59).millisecondsSinceEpoch ~/ 1000; final dayTwo = DateTime(2026, 7, 15).millisecondsSinceEpoch ~/ 1000; final grouped = groupMembershipTimelineEntries( entries([ _systemMsg( id: 'a', payload: { 'type': 'member_joined', 'actor': 'alice', 'target': 'bob', }, createdAt: dayOne, ), _systemMsg( id: 'b', payload: { 'type': 'member_joined', 'actor': 'carol', 'target': 'dave', }, createdAt: dayOne + 30, ), _textMsg(id: 'message', createdAt: dayOne + 40), _systemMsg( id: 'c', payload: { 'type': 'member_joined', 'actor': 'carol', 'target': 'erin', }, createdAt: dayOne + 50, ), _systemMsg( id: 'd', payload: { 'type': 'member_joined', 'actor': 'carol', 'target': 'frank', }, createdAt: dayTwo, ), ]), ); expect(grouped.map((group) => group.length), [1, 1, 1, 1, 1]); }); }); }