Files
buzz/mobile/test/features/channels/timeline_message_test.dart
06b60e682d fix(mobile): merge relay recounts with locally seen thread replies (#4633)
## Summary

- Keep mobile thread reply badges current by merging relay recounts with
replies observed locally.
- Retain replies in the local channel store while continuing to filter
them from the main timeline.
- Match the thread summary behavior already used on desktop, including
the reply count, latest reply time, and participant avatars.

## Why

On mobile, the "N replies" badge under a channel message can stall at a
stale count or remain missing after a reply arrives. This makes the
badge unreliable and can cause people to miss replies.

The badge has two inputs: best-effort recounts from the relay and
replies the client sees arrive. Mobile previously let any positive relay
recount override the local view, while also discarding replies from its
local message store. A delayed or lost recount, or a reply received
after the recount, could therefore leave the badge behind.

This change combines both inputs by using the higher reply count, the
later last-reply time, and a merged participant list. Relay timestamps
have one-second precision, so equal timestamps do not prove that a
recount included a locally observed reply. Comparing counts preserves
that reply instead of trusting recency alone. Desktop already uses this
merge behavior.

## Validation

At commit `4e3356636f5ad62e8f07910af305c532186c6c08` with a clean
worktree:

- `flutter test` for mobile: 1105 passed, 1 skipped
- `flutter analyze` for mobile: no issues found
- Reverting the merge so a positive relay recount shadows local replies
fails 4 of the new tests, including the same-second and
reply-after-recount cases. Restoring the store-level reply drop fails
both new provider tests.

Added tests:

-
[`timeline_message_test.dart`](https://github.com/block/buzz/tree/main/mobile/test/features/channels/timeline_message_test.dart),
covering relay-only recounts, a reply newer than the recount, a reply in
the same second as the recount, a lost recount, a zero recount, nested
replies at the root and at the reply they answer, a deleted reply, and
participant merging and capping.
-
[`channel_messages_provider_test.dart`](https://github.com/block/buzz/tree/main/mobile/test/features/channels/channel_messages_provider_test.dart),
covering a live reply reaching the store while staying out of the main
timeline, and a reply newer than the relay recount raising the badge.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: npub12uu53ml9upy7ww9apmtv6vm0u8xlcldx7znsjvwgsr7uvy5g0kssw943ca <573948efe5e049e738bd0ed6cd336fe1cdfc7da6f0a70931c880fdc612887da1@buzz.block.builderlab.xyz>
2026-08-05 16:06:54 -07: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]);
});
});
}