mirror of
https://github.com/block/buzz.git
synced 2026-11-02 15:31:27 +00:00
First slice of #2216, scoped to the system/status lines in the chat timeline. ## Why Two problems on the same surface. **Clearing a channel topic renders as empty quotes.** The relay reports a clear as a `topic_changed` event carrying an empty string — there's no separate "cleared" event type. So the timeline printed: > Alice > changed the topic to “” which reads as if the topic were *set to* two quote marks. Same for purpose. **The membership caption reads like a headline, not a metadata line.** `title` and `action` render on separate lines — the member's name sits in the header row with the avatar and timestamp, and the caption sits beneath it. So the caption was "was added by Alice Chen" standing alone under a name, while its siblings on that same line are "joined the channel" and "left the channel". ## What - Blank, missing, or whitespace-only topic/purpose now reads **"cleared the channel topic"** / **"cleared the channel purpose"**. - Membership captions drop "was": **"added by Alice Chen"**, matching "joined the channel" and "left the channel". - The wording moves to `lib/systemEventCopy.ts` as a pure function, so it's assertable in a unit test instead of only reachable through the DOM. That also removes two JSX fragments from `SystemMessageRow.tsx`, taking it 911 → 900 lines. ## Two E2E assertions this exposed Both were measuring something other than what they claimed, and the copy change tipped them over. Neither is a product bug, but both would have failed the next person too. 1. **`mentions.spec.ts:1245`** asserted a button was un-underlined while the mouse was still parked from a previous `hover()`. Any reflow — new rows, scroll-to-bottom, a different text wrap — can slide that button under the stationary pointer, so the assertion measured *where the mouse happened to be* rather than the resting style. Dropping four characters changed the text wrap, changed the row height, changed the scroll offset, and the pointer landed on it. Now parks the pointer off-target first. 2. **`mentions.spec.ts:1253`** used a bare `role=tooltip` lookup. Once the first tooltip animates out while the second opens, two elements match and strict mode trips. Now scopes to the open tooltip via `:not([data-state="closed"])`. ## Deliberately out of scope - **Timestamps.** The day divider, per-message clock times, the Inbox thread pane, and the inbox list have three divergent date implementations and none fully match the writing standard's Today/Yesterday/weekday/date progression. That's its own slice of #2216. - **Whose avatar shows.** An addition puts the *added* member in the header; a removal puts the *remover* there. Possibly intentional, but it's a design question, not copy. - **`the channel` vs `this channel`.** joined/left/removed say "the channel"; created/archived/unarchived say "this channel". Worth normalizing, but it touches lines this PR otherwise leaves alone. ## Validation - `pnpm check`, `pnpm typecheck` — clean - Unit: **3781/3781**, including 6 new tests in `systemEventCopy.test.mjs` covering set/blank/undefined/null/whitespace for both fields, plus a guard that no variant can emit empty quotes - Smoke E2E `mentions` + `messaging`: **85/85** - The previously fragile test run with `--repeat-each=5`: **5/5** Signed-off-by: Clay Delk <clay.delk@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1030 lines
28 KiB
Dart
1030 lines
28 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter_test/flutter_test.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 only direct children, not nested replies', () {
|
|
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);
|
|
// Only r1 is a direct child of a; r2 is a child of r1.
|
|
expect(entries[0].summary!.replyCount, 1);
|
|
});
|
|
|
|
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('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]);
|
|
});
|
|
});
|
|
}
|