mirror of
https://github.com/block/buzz.git
synced 2026-11-12 12:22:06 +00:00
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>
1497 lines
42 KiB
Dart
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]);
|
|
});
|
|
});
|
|
}
|