Files
buzz/mobile/test/features/channels/timeline_message_test.dart
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

1293 lines
37 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';
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), '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<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: '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<MainTimelineEntry> entries(List<NostrEvent> 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]);
});
});
}