Files
buzz/mobile/test/features/channels/timeline_message_test.dart
Tom Brow 9f88eb9c45 mobile: hide self-joins and leaves, collapse membership runs like desktop (#8962)
Busy channels fill the mobile timeline with page after page of small "X
joined the channel along with Y and N others" and "X added Y to the
channel" rows. Desktop now hides most of that
([buzz-app#2258](https://github.com/block/buzz-app/pull/2258)). This
brings mobile's membership notices in line with desktop's current rules
(`membershipRows` / `membershipDescription` in buzz-app
`src/features/messages/membership-rows.ts`).

### Behavior

- **Only explicit changes show.** Self-joins and voluntary leaves are
hidden. That covers both relay leave shapes: a normal Leave (kind 9022,
actor only) and removing yourself (kind 9001, with a target). Additions
by someone else and removals still show.
- **One row per run.** Each uninterrupted run of additions and removals
becomes one row, whatever the elapsed time, local day or actor. Messages
and other channel events (topic, archive, huddles) still split runs.
- **Each person appears once**, under their latest change in the run.
- **Wording:**
- One adder: "Alice added Bob, Carol and N others to the channel. See
all" (unchanged)
  - One remover: "Alice removed Bob ... from the channel" (unchanged)
- Several adders: "Bob, Carol and N others were added to the channel".
It no longer says they "joined".
- Additions and removals together, or several removers: "Bob and 4
others were added · Dan was removed". Each "N others" opens that list.

Mobile keeps its "See all" sheet in place of desktop's hover popover.

### Validation

- Added tests:
  - unit tests for hiding self-joins and both leave shapes
  - grouping across hours, days and actors
  - runs split by messages and other channel events
- a channel page journey with a self-join, a 9022 leave, a 9001 leave,
additions by two people hours apart and a removal: it renders one mixed
row, and the "1 other" sheet works
  - a several-adders wording test
- Updated tests that previously relied on visible self-joins or leaves.
- Full mobile suite (`just mobile-test`) and `flutter analyze` pass.
- Automated full-app run on an iPhone 17 Pro simulator, signed in to a
hosted community:
- The home list and a 33-member channel with membership history load.
Self-joins are gone, messages flow without them, and a two-adder run
reads "X and Y were added to the channel".
- Scrolling back through history and opening and closing a thread work.
  - Captures are not attached because they show internal names.
- Limitation: the internal community now rejects the simulator's
websocket without hosted sign-in, so the 1,500-member channel wasn't
re-checked on this head.
- Human testing is waived per the owner's standing policy.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
2026-11-03 15:50:33 -08:00

1497 lines
42 KiB
Dart

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';
import '../../helpers/thread_ids.dart';
NostrEvent _textMsg({
required String id,
String pubkey = 'alice',
String content = 'hello',
int createdAt = 1000,
List<List<String>>? 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<List<String>> 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<String, dynamic> 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<String> 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<String> 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<List<String>> 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<List<String>> 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), 'Alice added Bob to the channel');
});
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,
]),
);
expect(
EventKind.channelTimelineContentKinds,
containsAll([EventKind.huddleStarted, EventKind.huddleEnded]),
);
});
test('passes through text messages', () {
final events = [
_textMsg(id: threadIdA, 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: threadIdA, 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: threadIdA, 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: threadIdA, content: 'original'),
_edit(id: 'e1', targetId: threadIdA, 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: threadIdA,
content: 'original :old:',
extraTags: [
['emoji', 'old', 'https://relay.example/old.png'],
],
),
_edit(
id: 'e1',
targetId: threadIdA,
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: threadIdR1,
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: threadIdA, content: 'v1'),
_edit(id: 'e1', targetId: threadIdA, content: 'v2', createdAt: 2000),
_edit(id: 'e2', targetId: threadIdA, content: 'v3', createdAt: 3000),
];
final result = formatTimeline(events);
expect(result[0].content, 'v3');
});
test('deleted edit is ignored', () {
final events = [
_textMsg(id: threadIdA, content: 'original'),
_edit(id: 'e1', targetId: threadIdA, 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: threadIdA, content: 'original'),
_edit(id: 'e1', targetId: threadIdA, content: 'edited'),
_deletion(id: 'd1', targets: [threadIdA]),
];
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 room IDs normalize canonical uppercase UUIDs', () {
final event = NostrEvent(
id: 'upper',
pubkey: 'alice',
createdAt: 1000,
kind: EventKind.huddleStarted,
tags: const [],
content:
'{"ephemeral_channel_id":"8D764100-FD8F-44CF-9C98-6D8FBD739B8C"}',
sig: '',
);
expect(
SystemEvent.fromHuddleEvent(event)!.ephemeralChannelId,
'8d764100-fd8f-44cf-9c98-6d8fbd739b8c',
);
});
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[0].systemEvent!.ephemeralChannelId,
'8d764100-fd8f-44cf-9c98-6d8fbd739b8c',
);
expect(result[1].isSystem, isTrue);
expect(result[1].systemEvent!.type, SystemEventType.huddleEnded);
expect(
result[1].systemEvent!.ephemeralChannelId,
'8d764100-fd8f-44cf-9c98-6d8fbd739b8c',
);
});
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: threadIdA, 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: threadIdA, content: 'hello'),
_reaction(id: threadIdR1, targetId: threadIdA),
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: threadIdA, 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: threadIdA);
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: threadIdA,
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', threadIdRoot1, '', 'reply'],
],
);
final ref = event.threadReference;
expect(ref.parentId, threadIdRoot1);
expect(ref.rootId, threadIdRoot1);
});
test('parses nested reply (root + reply markers)', () {
final event = _textMsg(
id: 'reply2',
extraTags: [
['e', threadIdRoot1, '', 'root'],
['e', threadIdParent1, '', 'reply'],
],
);
final ref = event.threadReference;
expect(ref.parentId, threadIdParent1);
expect(ref.rootId, threadIdRoot1);
});
test('last reply tag wins when multiple exist', () {
final event = _textMsg(
id: 'reply3',
extraTags: [
['e', threadIdRoot1, '', 'root'],
['e', threadIdOldUnderscoreParent, '', 'reply'],
['e', threadIdNewUnderscoreParent, '', 'reply'],
],
);
final ref = event.threadReference;
expect(ref.parentId, threadIdNewUnderscoreParent);
expect(ref.rootId, threadIdRoot1);
});
});
group('formatTimeline threading', () {
test('root messages have null parentId and rootId', () {
final events = [_textMsg(id: threadIdA)];
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: threadIdRoot1),
_replyMsg(id: threadIdR1, parentId: threadIdRoot1, createdAt: 2000),
];
final result = formatTimeline(events);
final reply = result.firstWhere((m) => m.id == threadIdR1);
expect(reply.parentId, threadIdRoot1);
expect(reply.rootId, threadIdRoot1);
});
test('nested reply has distinct parentId and rootId', () {
final events = [
_textMsg(id: threadIdRoot1),
_replyMsg(id: threadIdR1, parentId: threadIdRoot1, createdAt: 2000),
_replyMsg(
id: 'r2',
parentId: threadIdR1,
rootId: threadIdRoot1,
createdAt: 3000,
),
];
final result = formatTimeline(events);
final nested = result.firstWhere((m) => m.id == 'r2');
expect(nested.parentId, threadIdR1);
expect(nested.rootId, threadIdRoot1);
});
});
group('buildMainTimelineEntries', () {
test(
'Me keeps agent replies inline without duplicate thread summaries',
() {
final messages = formatTimeline([
_textMsg(id: 'root', createdAt: 1000),
_replyMsg(id: 'answer', parentId: 'root', createdAt: 2000),
_textMsg(id: 'followup', createdAt: 3000),
]);
final entries = buildMainTimelineEntries(messages, flat: true);
expect(entries.map((e) => e.message.id), [
'root',
'answer',
'followup',
]);
expect(entries.every((e) => e.summary == null), isTrue);
},
);
test('returns only root messages', () {
final messages = formatTimeline([
_textMsg(id: threadIdA, createdAt: 1000),
_replyMsg(id: threadIdR1, parentId: threadIdA, createdAt: 2000),
_textMsg(id: 'b', createdAt: 3000),
]);
final entries = buildMainTimelineEntries(messages);
expect(entries, hasLength(2));
expect(entries[0].message.id, threadIdA);
expect(entries[1].message.id, 'b');
});
test('includes broadcast replies in the main timeline', () {
final messages = formatTimeline([
_textMsg(id: threadIdA, createdAt: 1000),
_replyMsg(id: 'hidden', parentId: threadIdA, createdAt: 2000),
_replyMsg(
id: 'broadcast',
parentId: threadIdA,
createdAt: 3000,
extraTags: const [
['broadcast', '1'],
],
),
]);
final entries = buildMainTimelineEntries(messages);
expect(entries.map((entry) => entry.message.id), [
threadIdA,
'broadcast',
]);
});
test('root without replies has null summary', () {
final messages = formatTimeline([_textMsg(id: threadIdA)]);
final entries = buildMainTimelineEntries(messages);
expect(entries[0].summary, isNull);
});
test('root with replies has summary with correct count', () {
final messages = formatTimeline([
_textMsg(id: threadIdA, createdAt: 1000),
_replyMsg(
id: threadIdR1,
parentId: threadIdA,
pubkey: 'bob',
createdAt: 2000,
),
_replyMsg(
id: 'r2',
parentId: threadIdA,
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, threadIdA);
expect(entries[0].summary!.lastReplyAt, 3000);
});
test('summary counts every loaded descendant, not only direct children', () {
final messages = formatTimeline([
_textMsg(id: threadIdA, createdAt: 1000),
_replyMsg(id: threadIdR1, parentId: threadIdA, createdAt: 2000),
_replyMsg(
id: 'r2',
parentId: threadIdR1,
rootId: threadIdA,
createdAt: 3000,
),
]);
final entries = buildMainTimelineEntries(messages);
// The root badge stands for the whole thread, so the reply to r1 counts
// towards threadIdA 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: threadIdA, createdAt: 1000),
_replyMsg(
id: threadIdR1,
parentId: threadIdA,
pubkey: 'bob',
createdAt: 2000,
),
_replyMsg(
id: 'r2',
parentId: threadIdA,
pubkey: 'carol',
createdAt: 3000,
),
_replyMsg(
id: 'r3',
parentId: threadIdA,
pubkey: 'bob',
createdAt: 4000,
),
_replyMsg(
id: 'r4',
parentId: threadIdA,
pubkey: 'dave',
createdAt: 5000,
),
_replyMsg(
id: 'r5',
parentId: threadIdA,
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: threadIdA, 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<String> 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: threadIdA, createdAt: 1000),
]);
final entries = buildMainTimelineEntries(
messages,
relaySummaries: {
threadIdA: 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: threadIdA, createdAt: 1000),
_replyMsg(
id: threadIdR1,
parentId: threadIdA,
pubkey: 'bob',
createdAt: 2000,
),
_replyMsg(
id: 'r2',
parentId: threadIdA,
pubkey: 'carol',
createdAt: 3000,
),
]);
// The relay counted only r1 before r2 landed.
final entries = buildMainTimelineEntries(
messages,
relaySummaries: {
threadIdA: 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: threadIdA, createdAt: 1000),
_replyMsg(
id: threadIdR1,
parentId: threadIdA,
pubkey: 'bob',
createdAt: 2000,
),
_replyMsg(
id: 'r2',
parentId: threadIdA,
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: {
threadIdA: 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: threadIdA, createdAt: 1000),
_replyMsg(
id: threadIdR1,
parentId: threadIdA,
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: threadIdA, createdAt: 1000),
_replyMsg(
id: threadIdR1,
parentId: threadIdA,
pubkey: 'bob',
createdAt: 2000,
),
]);
final entries = buildMainTimelineEntries(
messages,
relaySummaries: {
threadIdA: 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: threadIdA, createdAt: 1000),
_replyMsg(
id: threadIdR1,
parentId: threadIdA,
pubkey: 'bob',
createdAt: 2000,
),
]);
final entries = buildMainTimelineEntries(
messages,
relaySummaries: {
threadIdA: 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: threadIdA, createdAt: 1000),
_replyMsg(
id: threadIdR1,
parentId: threadIdA,
pubkey: 'bob',
createdAt: 2000,
),
_replyMsg(
id: 'r2',
parentId: threadIdR1,
rootId: threadIdA,
pubkey: 'carol',
createdAt: 3000,
),
]);
// The recount for threadIdA 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: {
threadIdA: relaySummary(
replyCount: 1,
lastReplyAt: 2000,
participantPubkeys: const ['bob'],
),
},
);
final root = entries.firstWhere((entry) => entry.message.id == threadIdA);
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: threadIdA, 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: {
threadIdA: relaySummary(
replyCount: 1,
descendantCount: 5,
lastReplyAt: 9000,
),
},
);
expect(entries.single.summary!.replyCount, 5);
});
test('relay direct replies are a lower bound for a broadcast row', () {
final messages = formatTimeline([
_textMsg(id: threadIdA),
_replyMsg(
id: 'broadcast',
parentId: threadIdA,
extraTags: const [
['broadcast', '1'],
],
),
]);
final entries = buildMainTimelineEntries(
messages,
relaySummaries: {
'broadcast': relaySummary(replyCount: 1, descendantCount: 0),
},
);
final summary = entries.last.summary!;
expect(summary.replyCount, 1);
expect(summary.isLowerBound, isTrue);
});
test('a nested reply badges the reply it answers', () {
final messages = formatTimeline([
_textMsg(id: threadIdA, createdAt: 1000),
_replyMsg(
id: threadIdR1,
parentId: threadIdA,
pubkey: 'bob',
createdAt: 2000,
extraTags: const [
['broadcast', '1'],
],
),
_replyMsg(
id: 'r2',
parentId: threadIdR1,
rootId: threadIdA,
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: {
threadIdA: relaySummary(replyCount: 1, lastReplyAt: 2000),
},
);
final byId = {for (final entry in entries) entry.message.id: entry};
expect(byId[threadIdA]!.summary!.replyCount, 2);
expect(byId[threadIdR1]!.summary!.replyCount, 1);
expect(byId[threadIdR1]!.summary!.participantPubkeys, ['carol']);
expect(byId[threadIdR1]!.summary!.lastReplyAt, 3000);
});
test('merged participants keep relay identities first, capped at 3', () {
final messages = formatTimeline([
_textMsg(id: threadIdA, createdAt: 1000),
_replyMsg(
id: threadIdR1,
parentId: threadIdA,
pubkey: 'bob',
createdAt: 2000,
),
_replyMsg(
id: 'r2',
parentId: threadIdA,
pubkey: 'carol',
createdAt: 3000,
),
]);
final entries = buildMainTimelineEntries(
messages,
relaySummaries: {
threadIdA: 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: threadIdA, createdAt: 1000),
_replyMsg(
id: threadIdR1,
parentId: threadIdA,
pubkey: 'bob',
createdAt: 2000,
),
_replyMsg(
id: 'r2',
parentId: threadIdA,
pubkey: 'carol',
createdAt: 3000,
),
_deletion(id: 'd1', targets: ['r2']),
]);
final entries = buildMainTimelineEntries(
messages,
relaySummaries: {
threadIdA: 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: threadIdA, createdAt: 1000),
_replyMsg(
id: threadIdR1,
parentId: threadIdA,
pubkey: 'BOB',
createdAt: 2000,
),
_replyMsg(
id: 'r2',
parentId: threadIdA,
pubkey: 'carol',
createdAt: 3000,
),
]);
final entries = buildMainTimelineEntries(
messages,
relaySummaries: {
threadIdA: relaySummary(
replyCount: 2,
lastReplyAt: 3000,
participantPubkeys: const ['carol', 'bob'],
),
},
);
expect(entries.single.summary!.participantPubkeys, ['carol', 'bob']);
});
});
group('groupMembershipTimelineEntries', () {
List<MainTimelineEntry> entries(List<NostrEvent> events) =>
buildMainTimelineEntries(formatTimeline(events));
test('groups consecutive additions by one actor', () {
final grouped = groupMembershipTimelineEntries(
entries([
_systemMsg(
id: threadIdA,
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), [
threadIdA,
'b',
'c',
]);
});
NostrEvent change(String id, Map<String, Object> payload, int createdAt) =>
_systemMsg(id: id, payload: payload, createdAt: createdAt);
test('hides self-joins and both shapes of voluntary leave', () {
final messages = formatTimeline([
change(threadIdA, {
'type': 'member_joined',
'actor': 'alice',
'target': 'alice',
}, 1000),
// A normal Leave (kind 9022) carries only the actor.
change('b', {'type': 'member_left', 'actor': 'bob'}, 1010),
// Removing yourself (kind 9001) also names the target.
change('c', {
'type': 'member_left',
'actor': 'carol',
'target': 'carol',
}, 1020),
change('d', {
'type': 'member_joined',
'actor': 'dave',
'target': 'erin',
}, 1030),
change('e', {
'type': 'member_removed',
'actor': 'dave',
'target': 'dave',
}, 1040),
change('f', {'type': 'topic_changed', 'actor': 'dave'}, 1050),
]);
expect(messages.map(isHiddenMembershipNotice), [
true,
true,
true,
false,
false,
false,
]);
});
test('groups additions and removals by anyone across hours and days', () {
final dayOne =
DateTime(2026, 7, 14, 23, 59).millisecondsSinceEpoch ~/ 1000;
final dayTwo = DateTime(2026, 7, 16, 9).millisecondsSinceEpoch ~/ 1000;
final grouped = groupMembershipTimelineEntries(
entries([
change(threadIdA, {
'type': 'member_joined',
'actor': 'alice',
'target': 'bob',
}, dayOne),
change('b', {
'type': 'member_removed',
'actor': 'carol',
'target': 'dave',
}, dayOne + 5 * 3600),
change('c', {
'type': 'member_removed',
'actor': 'erin',
'target': 'frank',
}, dayTwo),
change('d', {
'type': 'member_joined',
'actor': 'gina',
'target': 'hank',
}, dayTwo + 60),
]),
);
expect(grouped.single, hasLength(4));
});
test('messages and other channel events break groups', () {
final grouped = groupMembershipTimelineEntries(
entries([
change(threadIdA, {
'type': 'member_joined',
'actor': 'alice',
'target': 'bob',
}, 1000),
change('b', {
'type': 'member_joined',
'actor': 'carol',
'target': 'dave',
}, 1030),
_textMsg(id: 'message', createdAt: 1040),
change('c', {
'type': 'member_joined',
'actor': 'carol',
'target': 'erin',
}, 1050),
change('d', {'type': 'topic_changed', 'actor': 'carol'}, 1060),
change('e', {
'type': 'member_removed',
'actor': 'carol',
'target': 'erin',
}, 1070),
]),
);
expect(grouped.map((group) => group.length), [2, 1, 1, 1, 1]);
});
});
}