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:
Tom Brow
2026-08-05 16:06:54 -07:00
committed by GitHub
parent eb6a37569d
commit 06b60e682d
4 changed files with 543 additions and 48 deletions

View File

@@ -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: [

View File

@@ -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));