mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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>
This commit is contained in:
@@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:buzz/features/channels/channel_messages_provider.dart';
|
||||
import 'package:buzz/features/channels/pending_local_messages_provider.dart';
|
||||
import 'package:buzz/features/channels/thread_replies_provider.dart';
|
||||
import 'package:buzz/features/channels/timeline_message.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
|
||||
void main() {
|
||||
@@ -572,6 +573,106 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a live reply reaches the store so its parent badge can count it',
|
||||
() async {
|
||||
final relaySession = _RecordingRelaySessionNotifier(
|
||||
queryResults: [
|
||||
[_event(id: 'root', createdAt: 10), _bounds()],
|
||||
],
|
||||
);
|
||||
final container = _buildContainer(relaySession);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
container.read(channelMessagesProvider(_channelId));
|
||||
await relaySession.subscribed;
|
||||
await _pumpEventQueue();
|
||||
|
||||
relaySession.emit(
|
||||
_event(
|
||||
id: 'reply',
|
||||
createdAt: 20,
|
||||
extraTags: const [
|
||||
['e', 'root', '', 'reply'],
|
||||
],
|
||||
),
|
||||
);
|
||||
await _pumpEventQueue();
|
||||
|
||||
// The reply is retained as the local half of the summary merge. It is
|
||||
// filtered out of the main timeline by `buildMainTimelineEntries`, which
|
||||
// owns reply visibility.
|
||||
expect(
|
||||
container
|
||||
.read(channelMessagesProvider(_channelId))
|
||||
.value
|
||||
?.map((event) => event.id),
|
||||
['root', 'reply'],
|
||||
);
|
||||
expect(
|
||||
buildMainTimelineEntries(
|
||||
formatTimeline(
|
||||
container.read(channelMessagesProvider(_channelId)).value!,
|
||||
),
|
||||
relaySummaries: container
|
||||
.read(channelMessagesProvider(_channelId).notifier)
|
||||
.threadSummaries,
|
||||
).map((entry) => entry.message.id),
|
||||
['root'],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('a reply newer than the relay recount raises the badge', () async {
|
||||
final relaySession = _RecordingRelaySessionNotifier(
|
||||
queryResults: [
|
||||
[_event(id: 'root', createdAt: 10), _bounds()],
|
||||
],
|
||||
);
|
||||
final container = _buildContainer(relaySession);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
container.read(channelMessagesProvider(_channelId));
|
||||
await relaySession.subscribed;
|
||||
await _pumpEventQueue();
|
||||
|
||||
relaySession.emit(
|
||||
_event(
|
||||
id: 'reply-1',
|
||||
createdAt: 20,
|
||||
extraTags: const [
|
||||
['e', 'root', '', 'reply'],
|
||||
],
|
||||
),
|
||||
);
|
||||
relaySession.emit(_summary(rootId: 'root', replyCount: 1, createdAt: 20));
|
||||
// A second reply lands, and its recount is lost or still in flight.
|
||||
relaySession.emit(
|
||||
_event(
|
||||
id: 'reply-2',
|
||||
createdAt: 21,
|
||||
extraTags: const [
|
||||
['e', 'root', '', 'reply'],
|
||||
],
|
||||
),
|
||||
);
|
||||
await _pumpEventQueue();
|
||||
|
||||
final notifier = container.read(
|
||||
channelMessagesProvider(_channelId).notifier,
|
||||
);
|
||||
expect(notifier.threadSummaries['root']?.replyCount, 1);
|
||||
final entries = buildMainTimelineEntries(
|
||||
formatTimeline(
|
||||
container.read(channelMessagesProvider(_channelId)).value!,
|
||||
),
|
||||
relaySummaries: notifier.threadSummaries,
|
||||
);
|
||||
expect(entries.single.message.id, 'root');
|
||||
expect(entries.single.summary!.replyCount, 2);
|
||||
expect(entries.single.summary!.lastReplyAt, 21);
|
||||
});
|
||||
|
||||
test('window pagination failures return false without exhausting', () async {
|
||||
final relaySession = _RecordingRelaySessionNotifier(
|
||||
queryResults: [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
|
||||
@@ -826,17 +827,23 @@ void main() {
|
||||
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),
|
||||
]);
|
||||
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);
|
||||
// Only r1 is a direct child of a; r2 is a child of r1.
|
||||
expect(entries[0].summary!.replyCount, 1);
|
||||
});
|
||||
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([
|
||||
@@ -877,6 +884,262 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user