mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
fix(mobile): recover stale and shuffled messages (#6691)
## Summary - refetch mounted mobile thread replies after relay reconnect, preserving the previous reply list during recovery - auto-dispose route-scoped relay reply caches so reopening a thread queries current relay state - invalidate live replies through both the channel-window and legacy websocket-history paths - preserve optimistic-reply confirmation when the route closes before its deferred cleanup - stabilize rapid same-second messages using desktop's existing split contract: channel timelines render `(created_at ASC, id DESC)` while threads render `(created_at ASC, id ASC)` - retain late live rows after a channel window is exhausted instead of dropping same-second tail messages Closes #4404. Closes #4830. Closes #6204. ## Context The broad all-channel/all-DM stale-session defect reported in #4402 is already addressed on current `main` by #4372 and #3053. Two distinct mobile gaps remained: 1. `threadRepliesProvider` was a process-lifetime one-shot query, so replies missed while the socket was stale remained absent after reconnect or after closing and reopening the thread. 2. Mobile had inconsistent timestamp-only and event-id ordering across channel producers. Rapid messages routinely share Nostr's one-second timestamp, so later hydration/live reconciliation could reshuffle them. Desktop deliberately has two render contracts: channel windows reverse the relay's composite order to `(created_at ASC, id DESC)`, while thread replies use `(created_at ASC, id ASC)`. This consolidates the current-main portions of #4831 and #3243 rather than reviving stale overlapping branches. ## Validation Exact pushed head: `be92d9542c6cd1342733bdc5e8359664b511ce02` - focused channel-provider/window/thread suites: 48/48 passed - incident regression: a mounted thread misses a reply while disconnected, reconnects, and renders the recovered reply - route regression: closing and reopening a thread performs a fresh authoritative query - websocket fallback regression: live reply invalidates the mounted thread even without the channel-window path - disposal regression: optimistic confirmation survives provider disposal between rebuild and deferred cleanup - ordering regressions: channel window/live, websocket fallback, optimistic sends, deep links, both pagination paths, and thread merges preserve their desktop-compatible same-second order - boundary regression: exhausted windows admit late same-second live rows without weakening open-page cursor boundaries - independent adversarial review: no production blocker; source contract verified across all producers and relay cursor semantics unchanged - pre-push Mobile lane passed at exact head, including analysis, file-size/branch checks, and full Flutter suite: 1,675/1,675 passed - `git diff --check` --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
This commit is contained in:
23
mobile/lib/features/channels/channel_event_order.dart
Normal file
23
mobile/lib/features/channels/channel_event_order.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import '../../shared/relay/relay.dart';
|
||||
|
||||
/// Render order for top-level channel timelines.
|
||||
///
|
||||
/// The relay pages newest timestamp first and ascending id within a second.
|
||||
/// Channel timelines reverse that composite order for display, matching
|
||||
/// desktop: oldest timestamp first and descending id within a second.
|
||||
int compareChannelTimelineEventsChronologically(
|
||||
NostrEvent left,
|
||||
NostrEvent right,
|
||||
) {
|
||||
final createdAt = left.createdAt.compareTo(right.createdAt);
|
||||
return createdAt != 0 ? createdAt : right.id.compareTo(left.id);
|
||||
}
|
||||
|
||||
/// Render order for thread replies.
|
||||
///
|
||||
/// Desktop threads use ascending id within a second, independently of the
|
||||
/// channel-window display order.
|
||||
int compareThreadRepliesChronologically(NostrEvent left, NostrEvent right) {
|
||||
final createdAt = left.createdAt.compareTo(right.createdAt);
|
||||
return createdAt != 0 ? createdAt : left.id.compareTo(right.id);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/relay/relay.dart';
|
||||
import 'channel_event_order.dart';
|
||||
import 'pending_local_messages_provider.dart';
|
||||
import 'channel_window.dart';
|
||||
import 'thread_replies_provider.dart';
|
||||
@@ -158,7 +159,7 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
|
||||
final history = await session.fetchHistory(
|
||||
NostrFilters.messages(channelId),
|
||||
);
|
||||
history.sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
history.sort(compareChannelTimelineEventsChronologically);
|
||||
return history;
|
||||
}
|
||||
}
|
||||
@@ -187,6 +188,9 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
|
||||
);
|
||||
|
||||
void _handleLiveEvent(NostrEvent event, {bool authoritative = true}) {
|
||||
// Invalidate the thread query independently of the selected channel-history
|
||||
// path. The websocket fallback does not merge through the window store.
|
||||
_invalidateThreadReplies(event);
|
||||
// A live summary can race the initial channel-window query. Buffer it in
|
||||
// the window store even before that query installs its first page, rather
|
||||
// than treating metadata as an ordinary websocket timeline event.
|
||||
@@ -232,28 +236,35 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
|
||||
state = AsyncData(flattened);
|
||||
}
|
||||
|
||||
void _invalidateThreadReplies(NostrEvent event) {
|
||||
if (!EventKind.channelTimelineContentKinds.contains(event.kind)) return;
|
||||
final thread = event.threadReference;
|
||||
if (thread.parentId == null) return;
|
||||
|
||||
final rootId = thread.rootId;
|
||||
if (rootId != null) {
|
||||
ref.invalidate(
|
||||
threadRepliesProvider(
|
||||
ThreadRepliesArgs(channelId: channelId, rootId: rootId),
|
||||
),
|
||||
);
|
||||
}
|
||||
final parentId = thread.parentId;
|
||||
if (parentId != null && parentId != rootId) {
|
||||
ref.invalidate(
|
||||
threadRepliesProvider(
|
||||
ThreadRepliesArgs(channelId: channelId, rootId: parentId),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _mergeWindowEventIntoStore(NostrEvent event) {
|
||||
final isTimelineRow = EventKind.channelTimelineContentKinds.contains(
|
||||
event.kind,
|
||||
);
|
||||
final thread = isTimelineRow ? event.threadReference : null;
|
||||
if (thread?.parentId != null) {
|
||||
final rootId = thread?.rootId;
|
||||
if (rootId != null) {
|
||||
ref.invalidate(
|
||||
threadRepliesProvider(
|
||||
ThreadRepliesArgs(channelId: channelId, rootId: rootId),
|
||||
),
|
||||
);
|
||||
}
|
||||
final parentId = thread?.parentId;
|
||||
if (parentId != null && parentId != rootId) {
|
||||
ref.invalidate(
|
||||
threadRepliesProvider(
|
||||
ThreadRepliesArgs(channelId: channelId, rootId: parentId),
|
||||
),
|
||||
);
|
||||
}
|
||||
// Replies are kept in the store rather than dropped here, matching
|
||||
// desktop: the main timeline filters them out at render
|
||||
// (`buildMainTimelineEntries`), and their parent's "N replies" row needs
|
||||
@@ -373,10 +384,7 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
|
||||
) {
|
||||
if (current.any((e) => e.id == incoming.id)) return current;
|
||||
final updated = [...current, incoming];
|
||||
updated.sort((a, b) {
|
||||
final createdAt = a.createdAt.compareTo(b.createdAt);
|
||||
return createdAt != 0 ? createdAt : a.id.compareTo(b.id);
|
||||
});
|
||||
updated.sort(compareChannelTimelineEventsChronologically);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -442,7 +450,7 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
|
||||
return [
|
||||
...events,
|
||||
..._deepLinkEvents.values.where((event) => ids.add(event.id)),
|
||||
]..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
]..sort(compareChannelTimelineEventsChronologically);
|
||||
}
|
||||
|
||||
Future<bool> fetchOlder() async {
|
||||
@@ -491,7 +499,7 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
|
||||
}
|
||||
state = state.whenData((events) {
|
||||
final merged = [...deduped, ...events];
|
||||
merged.sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
merged.sort(compareChannelTimelineEventsChronologically);
|
||||
_lastKnownMessages = merged;
|
||||
return merged;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../shared/relay/relay.dart';
|
||||
import 'channel_event_order.dart';
|
||||
|
||||
class ChannelPageCursor {
|
||||
final int createdAt;
|
||||
@@ -330,7 +331,11 @@ ChannelWindowStore mergeLiveChannelWindowEvent(
|
||||
final oldest = oldestPage?.rows.isEmpty ?? true
|
||||
? null
|
||||
: oldestPage!.rows.last.event;
|
||||
if (oldest != null && _compareRelayOrder(event, oldest) >= 0) return current;
|
||||
if (oldest != null &&
|
||||
(event.createdAt < oldest.createdAt ||
|
||||
(oldestPage!.hasMore && _compareRelayOrder(event, oldest) >= 0))) {
|
||||
return current;
|
||||
}
|
||||
final overlay =
|
||||
current.liveOverlay
|
||||
.where((candidate) => candidate.id != event.id)
|
||||
@@ -362,7 +367,7 @@ List<NostrEvent> flattenChannelWindowEvents(ChannelWindowStore store) {
|
||||
byId[event.id] = event;
|
||||
}
|
||||
return byId.values.toList()
|
||||
..sort((left, right) => _compareRelayOrder(right, left));
|
||||
..sort(compareChannelTimelineEventsChronologically);
|
||||
}
|
||||
|
||||
bool channelWindowHasMore(ChannelWindowStore store) =>
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/relay/relay.dart';
|
||||
import 'channel_event_order.dart';
|
||||
import 'pending_local_messages_provider.dart';
|
||||
|
||||
class ThreadRepliesArgs {
|
||||
@@ -29,12 +30,18 @@ class _ThreadCursor {
|
||||
const _ThreadCursor({required this.createdAt, required this.eventId});
|
||||
}
|
||||
|
||||
final threadRepliesProvider =
|
||||
FutureProvider.family<List<NostrEvent>, ThreadRepliesArgs>((
|
||||
ref,
|
||||
args,
|
||||
) async {
|
||||
final session = ref.watch(relaySessionProvider.notifier);
|
||||
final threadRepliesProvider = FutureProvider.autoDispose
|
||||
.family<List<NostrEvent>, ThreadRepliesArgs>((ref, args) async {
|
||||
// A reply missed while the socket is stale cannot invalidate this
|
||||
// one-shot query. Refresh mounted threads when the session recovers;
|
||||
// auto-dispose also makes reopening a thread start from relay truth.
|
||||
ref.listen(relaySessionProvider, (previous, next) {
|
||||
if (previous?.status != SessionStatus.connected &&
|
||||
next.status == SessionStatus.connected) {
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
});
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
final replies = <NostrEvent>[];
|
||||
_ThreadCursor? cursor;
|
||||
for (var page = 0; page < 500; page++) {
|
||||
@@ -99,24 +106,26 @@ final threadLocalRepliesProvider =
|
||||
|
||||
/// Relay-backed replies merged with signed local replies that are still
|
||||
/// waiting for acknowledgement.
|
||||
final threadRepliesWithLocalProvider =
|
||||
Provider.family<AsyncValue<List<NostrEvent>>, ThreadRepliesArgs>((
|
||||
ref,
|
||||
args,
|
||||
) {
|
||||
///
|
||||
/// The relay query is route-scoped, while the optimistic local overlay stays
|
||||
/// alive until confirmation so it can survive closing and reopening a thread.
|
||||
final threadRepliesWithLocalProvider = Provider.autoDispose
|
||||
.family<AsyncValue<List<NostrEvent>>, ThreadRepliesArgs>((ref, args) {
|
||||
final relayReplies = ref.watch(threadRepliesProvider(args));
|
||||
final localReplies = ref.watch(threadLocalRepliesProvider(args));
|
||||
final authoritative = relayReplies.value;
|
||||
if (authoritative != null && localReplies.isNotEmpty) {
|
||||
final authoritativeIds = authoritative.map((event) => event.id).toSet();
|
||||
if (localReplies.any((event) => authoritativeIds.contains(event.id))) {
|
||||
final localRepliesNotifier = ref.read(
|
||||
threadLocalRepliesProvider(args).notifier,
|
||||
);
|
||||
final pendingMessagesNotifier = ref.read(
|
||||
pendingLocalMessagesProvider(args.channelId).notifier,
|
||||
);
|
||||
Future.microtask(() {
|
||||
ref
|
||||
.read(threadLocalRepliesProvider(args).notifier)
|
||||
.confirm(authoritativeIds);
|
||||
ref
|
||||
.read(pendingLocalMessagesProvider(args.channelId).notifier)
|
||||
.confirm(authoritativeIds);
|
||||
localRepliesNotifier.confirm(authoritativeIds);
|
||||
pendingMessagesNotifier.confirm(authoritativeIds);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -147,8 +156,5 @@ List<NostrEvent> _mergeReplies(
|
||||
for (final event in [...first, ...second]) {
|
||||
byId[event.id] = event;
|
||||
}
|
||||
return byId.values.toList()..sort((a, b) {
|
||||
final createdAt = a.createdAt.compareTo(b.createdAt);
|
||||
return createdAt != 0 ? createdAt : a.id.compareTo(b.id);
|
||||
});
|
||||
return byId.values.toList()..sort(compareThreadRepliesChronologically);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,67 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'initial window hydration preserves equal-second live message order',
|
||||
() async {
|
||||
final window = Completer<List<NostrEvent>>();
|
||||
final relaySession = _RecordingRelaySessionNotifier(
|
||||
queryResults: [window.future],
|
||||
);
|
||||
final container = _buildContainer(relaySession);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
container.read(channelMessagesProvider(_channelId));
|
||||
await relaySession.subscribed;
|
||||
|
||||
relaySession.emit(_event(id: 'z-live', createdAt: 20));
|
||||
relaySession.emit(_event(id: 'a-live', createdAt: 20));
|
||||
await _pumpEventQueue();
|
||||
expect(
|
||||
container
|
||||
.read(channelMessagesProvider(_channelId))
|
||||
.value
|
||||
?.map((event) => event.id),
|
||||
['z-live', 'a-live'],
|
||||
);
|
||||
|
||||
window.complete([_bounds()]);
|
||||
await _pumpEventQueue();
|
||||
expect(
|
||||
container
|
||||
.read(channelMessagesProvider(_channelId))
|
||||
.value
|
||||
?.map((event) => event.id),
|
||||
['z-live', 'a-live'],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'websocket fallback uses desktop channel order for equal-second history',
|
||||
() async {
|
||||
final relaySession = _RecordingRelaySessionNotifier();
|
||||
final container = _buildContainer(relaySession);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
container.read(channelMessagesProvider(_channelId));
|
||||
await relaySession.subscribed;
|
||||
relaySession.completeHistory([
|
||||
_event(id: 'a-history', createdAt: 10),
|
||||
_event(id: 'z-history', createdAt: 10),
|
||||
]);
|
||||
await _pumpEventQueue();
|
||||
|
||||
expect(
|
||||
container
|
||||
.read(channelMessagesProvider(_channelId))
|
||||
.value
|
||||
?.map((event) => event.id),
|
||||
['z-history', 'a-history'],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'buffers a live thread summary until the initial window is installed',
|
||||
() async {
|
||||
@@ -203,8 +264,14 @@ void main() {
|
||||
channelMessagesProvider(_channelId).notifier,
|
||||
);
|
||||
|
||||
final targetLoad = notifier.loadEventsById(const ['target']);
|
||||
relaySession.completeTargetHistory([_event(id: 'target', createdAt: 5)]);
|
||||
final targetLoad = notifier.loadEventsById(const [
|
||||
'a-target',
|
||||
'z-target',
|
||||
]);
|
||||
relaySession.completeTargetHistory([
|
||||
_event(id: 'a-target', createdAt: 10),
|
||||
_event(id: 'z-target', createdAt: 10),
|
||||
]);
|
||||
await targetLoad;
|
||||
|
||||
expect(
|
||||
@@ -212,7 +279,7 @@ void main() {
|
||||
isTrue,
|
||||
);
|
||||
|
||||
relaySession.completeHistory([_event(id: 'history', createdAt: 10)]);
|
||||
relaySession.completeHistory([_event(id: 'm-history', createdAt: 10)]);
|
||||
await _pumpEventQueue();
|
||||
|
||||
expect(
|
||||
@@ -220,7 +287,7 @@ void main() {
|
||||
.read(channelMessagesProvider(_channelId))
|
||||
.value
|
||||
?.map((event) => event.id),
|
||||
['target', 'history'],
|
||||
['z-target', 'm-history', 'a-target'],
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -436,7 +503,11 @@ void main() {
|
||||
await relaySession.subscribed;
|
||||
await _pumpEventQueue();
|
||||
const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root');
|
||||
container.read(threadRepliesWithLocalProvider(args));
|
||||
final threadSubscription = container.listen(
|
||||
threadRepliesWithLocalProvider(args),
|
||||
(_, _) {},
|
||||
);
|
||||
addTearDown(threadSubscription.close);
|
||||
await _pumpEventQueue();
|
||||
final notifier = container.read(
|
||||
channelMessagesProvider(_channelId).notifier,
|
||||
@@ -467,7 +538,6 @@ void main() {
|
||||
|
||||
relaySession.emit(reply);
|
||||
await container.read(threadRepliesProvider(args).future);
|
||||
container.read(threadRepliesWithLocalProvider(args));
|
||||
await _pumpEventQueue();
|
||||
expect(
|
||||
container
|
||||
@@ -515,7 +585,11 @@ void main() {
|
||||
await relaySession.subscribed;
|
||||
await _pumpEventQueue();
|
||||
const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root');
|
||||
container.read(threadRepliesWithLocalProvider(args));
|
||||
final threadSubscription = container.listen(
|
||||
threadRepliesWithLocalProvider(args),
|
||||
(_, _) {},
|
||||
);
|
||||
addTearDown(threadSubscription.close);
|
||||
await _pumpEventQueue();
|
||||
final notifier = container.read(
|
||||
channelMessagesProvider(_channelId).notifier,
|
||||
@@ -551,6 +625,61 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'websocket fallback refetches an open thread when a reply arrives live',
|
||||
() async {
|
||||
final relaySession = _RecordingRelaySessionNotifier(
|
||||
queryResults: [
|
||||
Exception('channel window unavailable'),
|
||||
<NostrEvent>[],
|
||||
[
|
||||
_event(
|
||||
id: 'reply',
|
||||
createdAt: 20,
|
||||
extraTags: const [
|
||||
['e', 'root', '', 'reply'],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
final container = _buildContainer(relaySession);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
container.read(channelMessagesProvider(_channelId));
|
||||
await relaySession.subscribed;
|
||||
relaySession.completeHistory([_event(id: 'history', createdAt: 10)]);
|
||||
await _pumpEventQueue();
|
||||
expect(relaySession.operations, ['subscribe', 'query', 'fetch']);
|
||||
|
||||
const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root');
|
||||
final subscription = container.listen(
|
||||
threadRepliesProvider(args),
|
||||
(_, _) {},
|
||||
);
|
||||
addTearDown(subscription.close);
|
||||
expect(await container.read(threadRepliesProvider(args).future), isEmpty);
|
||||
|
||||
relaySession.emit(
|
||||
_event(
|
||||
id: 'reply',
|
||||
createdAt: 20,
|
||||
extraTags: const [
|
||||
['e', 'root', '', 'reply'],
|
||||
],
|
||||
),
|
||||
);
|
||||
await _pumpEventQueue();
|
||||
|
||||
expect(
|
||||
(await container.read(
|
||||
threadRepliesProvider(args).future,
|
||||
)).map((event) => event.id),
|
||||
['reply'],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'successful never-echoed send releases ownership but keeps its row across reconnect',
|
||||
() async {
|
||||
@@ -724,6 +853,78 @@ void main() {
|
||||
expect(entries.single.summary!.lastReplyAt, 21);
|
||||
});
|
||||
|
||||
test(
|
||||
'legacy pagination preserves desktop equal-second channel order',
|
||||
() async {
|
||||
final relaySession = _RecordingRelaySessionNotifier(
|
||||
historyResults: [
|
||||
[_event(id: 'a-head', createdAt: 20)],
|
||||
[
|
||||
_event(id: 'm-older', createdAt: 20),
|
||||
_event(id: 'z-older', createdAt: 20),
|
||||
],
|
||||
],
|
||||
);
|
||||
final container = _buildContainer(relaySession);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
container.read(channelMessagesProvider(_channelId));
|
||||
await relaySession.subscribed;
|
||||
await _pumpEventQueue();
|
||||
|
||||
final notifier = container.read(
|
||||
channelMessagesProvider(_channelId).notifier,
|
||||
);
|
||||
await expectLater(notifier.fetchOlder(), completion(isTrue));
|
||||
|
||||
expect(
|
||||
container
|
||||
.read(channelMessagesProvider(_channelId))
|
||||
.value
|
||||
?.map((event) => event.id),
|
||||
['z-older', 'm-older', 'a-head'],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'window pagination preserves desktop equal-second channel order',
|
||||
() async {
|
||||
final relaySession = _RecordingRelaySessionNotifier(
|
||||
queryResults: [
|
||||
[
|
||||
_event(id: 'a-head', createdAt: 20),
|
||||
_bounds(hasMore: true, cursorCreatedAt: 20, cursorId: 'a-head'),
|
||||
],
|
||||
[
|
||||
_event(id: 'm-older', createdAt: 20),
|
||||
_event(id: 'z-older', createdAt: 20),
|
||||
_bounds(dTag: '${_channelId.toLowerCase()}:20:a-head'),
|
||||
],
|
||||
],
|
||||
);
|
||||
final container = _buildContainer(relaySession);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
container.read(channelMessagesProvider(_channelId));
|
||||
await relaySession.subscribed;
|
||||
await _pumpEventQueue();
|
||||
|
||||
final notifier = container.read(
|
||||
channelMessagesProvider(_channelId).notifier,
|
||||
);
|
||||
await expectLater(notifier.fetchOlder(), completion(isTrue));
|
||||
|
||||
expect(
|
||||
container
|
||||
.read(channelMessagesProvider(_channelId))
|
||||
.value
|
||||
?.map((event) => event.id),
|
||||
['z-older', 'm-older', 'a-head'],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('window pagination failures return false without exhausting', () async {
|
||||
final relaySession = _RecordingRelaySessionNotifier(
|
||||
queryResults: [
|
||||
@@ -867,6 +1068,7 @@ Future<void> _pumpEventQueue() async {
|
||||
class _RecordingRelaySessionNotifier extends RelaySessionNotifier {
|
||||
final bool failSubscribe;
|
||||
final Queue<Object> _queryResults;
|
||||
final Queue<List<NostrEvent>> _historyResults;
|
||||
final List<String> operations = [];
|
||||
final List<NostrFilter> liveFilters = [];
|
||||
final List<NostrFilter> historyFilters = [];
|
||||
@@ -879,7 +1081,9 @@ class _RecordingRelaySessionNotifier extends RelaySessionNotifier {
|
||||
_RecordingRelaySessionNotifier({
|
||||
this.failSubscribe = false,
|
||||
List<Object> queryResults = const [],
|
||||
}) : _queryResults = Queue<Object>.of(queryResults);
|
||||
List<List<NostrEvent>> historyResults = const [],
|
||||
}) : _queryResults = Queue<Object>.of(queryResults),
|
||||
_historyResults = Queue<List<NostrEvent>>.of(historyResults);
|
||||
|
||||
Future<void> get subscribed => _subscribed.future;
|
||||
|
||||
@@ -918,6 +1122,9 @@ class _RecordingRelaySessionNotifier extends RelaySessionNotifier {
|
||||
_targetHistories.add(completer);
|
||||
return completer.future;
|
||||
}
|
||||
if (_historyResults.isNotEmpty) {
|
||||
return Future.value(_historyResults.removeFirst());
|
||||
}
|
||||
return _history.future;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,27 @@ void main() {
|
||||
});
|
||||
|
||||
group('ChannelWindowStore', () {
|
||||
test('flattens equal-second rows in desktop channel order', () {
|
||||
final store = replaceNewestChannelWindow(
|
||||
const ChannelWindowStore.empty(),
|
||||
_page(
|
||||
rows: [_row('a', createdAt: 10), _row('m', createdAt: 10)],
|
||||
hasMore: false,
|
||||
),
|
||||
);
|
||||
final withLive = mergeLiveChannelWindowEvent(
|
||||
store,
|
||||
_row('z', createdAt: 10),
|
||||
isTimelineRow: true,
|
||||
);
|
||||
|
||||
expect(flattenChannelWindowEvents(withLive).map((event) => event.id), [
|
||||
'z',
|
||||
'm',
|
||||
'a',
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects cursor interval and row order violations', () {
|
||||
expect(
|
||||
() => appendOlderChannelWindow(
|
||||
@@ -121,25 +142,42 @@ void main() {
|
||||
expect(refreshed.pages.single.rows.single.event.id, 'b');
|
||||
});
|
||||
|
||||
test('drops live rows at or older than oldest loaded boundary', () {
|
||||
final store = replaceNewestChannelWindow(
|
||||
const ChannelWindowStore.empty(),
|
||||
_page(rows: [_row('a', createdAt: 10), _row('m', createdAt: 9)]),
|
||||
);
|
||||
final ignored = mergeLiveChannelWindowEvent(
|
||||
store,
|
||||
_row('z', createdAt: 8),
|
||||
isTimelineRow: true,
|
||||
);
|
||||
expect(identical(ignored, store), isTrue);
|
||||
test(
|
||||
'keeps the loaded boundary closed but accepts exhausted live rows',
|
||||
() {
|
||||
final store = replaceNewestChannelWindow(
|
||||
const ChannelWindowStore.empty(),
|
||||
_page(
|
||||
rows: [_row('a', createdAt: 10), _row('m', createdAt: 9)],
|
||||
hasMore: true,
|
||||
),
|
||||
);
|
||||
final ignored = mergeLiveChannelWindowEvent(
|
||||
store,
|
||||
_row('z', createdAt: 8),
|
||||
isTimelineRow: true,
|
||||
);
|
||||
expect(identical(ignored, store), isTrue);
|
||||
|
||||
final merged = mergeLiveChannelWindowEvent(
|
||||
store,
|
||||
_row('live', createdAt: 11),
|
||||
isTimelineRow: true,
|
||||
);
|
||||
expect(merged.liveOverlay.map((event) => event.id), ['live']);
|
||||
});
|
||||
final merged = mergeLiveChannelWindowEvent(
|
||||
store,
|
||||
_row('live', createdAt: 11),
|
||||
isTimelineRow: true,
|
||||
);
|
||||
expect(merged.liveOverlay.map((event) => event.id), ['live']);
|
||||
|
||||
final exhausted = replaceNewestChannelWindow(
|
||||
const ChannelWindowStore.empty(),
|
||||
_page(rows: [_row('a', createdAt: 10)], hasMore: false),
|
||||
);
|
||||
final sameSecond = mergeLiveChannelWindowEvent(
|
||||
exhausted,
|
||||
_row('z', createdAt: 10),
|
||||
isTimelineRow: true,
|
||||
);
|
||||
expect(sameSecond.liveOverlay.map((event) => event.id), ['z']);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('live thread summaries', () {
|
||||
|
||||
238
mobile/test/features/channels/thread_replies_provider_test.dart
Normal file
238
mobile/test/features/channels/thread_replies_provider_test.dart
Normal file
@@ -0,0 +1,238 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:buzz/features/channels/pending_local_messages_provider.dart';
|
||||
import 'package:buzz/features/channels/thread_replies_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
class _FakeRelaySession extends RelaySessionNotifier {
|
||||
int queryCount = 0;
|
||||
List<NostrEvent> replies = const [];
|
||||
Completer<List<NostrEvent>>? nextQueryGate;
|
||||
|
||||
@override
|
||||
SessionState build() => const SessionState(status: SessionStatus.connected);
|
||||
|
||||
void setStatus(SessionStatus status) {
|
||||
state = SessionState(status: status);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<NostrEvent>> queryRelay(
|
||||
List<NostrFilter> filters, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
queryCount++;
|
||||
final gate = nextQueryGate;
|
||||
if (gate != null) {
|
||||
nextQueryGate = null;
|
||||
return gate.future;
|
||||
}
|
||||
return replies;
|
||||
}
|
||||
}
|
||||
|
||||
NostrEvent _reply(String id, int createdAt) => NostrEvent(
|
||||
id: id,
|
||||
pubkey: 'bob',
|
||||
createdAt: createdAt,
|
||||
kind: EventKind.streamMessage,
|
||||
tags: const [
|
||||
['h', 'chan'],
|
||||
['e', 'root', '', 'reply'],
|
||||
],
|
||||
content: 'reply $id',
|
||||
sig: '',
|
||||
);
|
||||
|
||||
void main() {
|
||||
const args = ThreadRepliesArgs(channelId: 'chan', rootId: 'root');
|
||||
|
||||
(ProviderContainer, _FakeRelaySession, ProviderSubscription<Object?>)
|
||||
makeHarness(List<NostrEvent> initialReplies) {
|
||||
final fakeSession = _FakeRelaySession()..replies = initialReplies;
|
||||
final container = ProviderContainer(
|
||||
overrides: [relaySessionProvider.overrideWith(() => fakeSession)],
|
||||
);
|
||||
// An auto-disposed provider needs a listener to stay alive, mirroring an
|
||||
// open thread page. Creating it starts the first load, so the fake's
|
||||
// replies must be in place first.
|
||||
final subscription = container.listen(
|
||||
threadRepliesProvider(args),
|
||||
(_, _) {},
|
||||
);
|
||||
return (container, fakeSession, subscription);
|
||||
}
|
||||
|
||||
test('thread replies keep desktop same-second id order', () {
|
||||
expect(
|
||||
mergeThreadEvents(
|
||||
[_reply('z', 1000)],
|
||||
[_reply('a', 1000), _reply('m', 1000)],
|
||||
).map((event) => event.id),
|
||||
['a', 'm', 'z'],
|
||||
);
|
||||
});
|
||||
|
||||
test('does not refetch on the disconnect edge', () async {
|
||||
final (container, fakeSession, _) = makeHarness([_reply('r1', 1000)]);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(threadRepliesProvider(args).future);
|
||||
final queriesAfterFirstLoad = fakeSession.queryCount;
|
||||
|
||||
fakeSession.setStatus(SessionStatus.disconnected);
|
||||
await container.pump();
|
||||
|
||||
expect(fakeSession.queryCount, queriesAfterFirstLoad);
|
||||
});
|
||||
|
||||
test('refetches exactly once per reconnect edge', () async {
|
||||
final (container, fakeSession, _) = makeHarness([_reply('r1', 1000)]);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final first = await container.read(threadRepliesProvider(args).future);
|
||||
expect(first.map((event) => event.id), ['r1']);
|
||||
final queriesAfterFirstLoad = fakeSession.queryCount;
|
||||
|
||||
// A reply lands while the connection is down.
|
||||
fakeSession.replies = [_reply('r1', 1000), _reply('r2', 2000)];
|
||||
fakeSession.setStatus(SessionStatus.disconnected);
|
||||
await container.pump();
|
||||
fakeSession.setStatus(SessionStatus.connected);
|
||||
await container.pump();
|
||||
|
||||
final second = await container.read(threadRepliesProvider(args).future);
|
||||
expect(second.map((event) => event.id), ['r1', 'r2']);
|
||||
expect(fakeSession.queryCount, queriesAfterFirstLoad + 1);
|
||||
});
|
||||
|
||||
test(
|
||||
'does not refetch on session emissions that keep the same status',
|
||||
() async {
|
||||
final (container, fakeSession, _) = makeHarness([_reply('r1', 1000)]);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(threadRepliesProvider(args).future);
|
||||
final queriesAfterFirstLoad = fakeSession.queryCount;
|
||||
|
||||
// Same connected status, new state object (e.g. reconnectAttempt bump).
|
||||
fakeSession.setStatus(SessionStatus.connected);
|
||||
await container.pump();
|
||||
|
||||
expect(fakeSession.queryCount, queriesAfterFirstLoad);
|
||||
},
|
||||
);
|
||||
|
||||
test('keeps previous replies available while a refresh is pending', () async {
|
||||
final (container, fakeSession, _) = makeHarness([_reply('r1', 1000)]);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(threadRepliesProvider(args).future);
|
||||
|
||||
// Hold the reconnect refresh open and verify the old data still reads.
|
||||
final gate = Completer<List<NostrEvent>>();
|
||||
fakeSession.nextQueryGate = gate;
|
||||
fakeSession.setStatus(SessionStatus.disconnected);
|
||||
await container.pump();
|
||||
fakeSession.setStatus(SessionStatus.connected);
|
||||
await container.pump();
|
||||
|
||||
final pending = container.read(threadRepliesProvider(args));
|
||||
expect(pending.isLoading, isTrue);
|
||||
expect(pending.value?.map((event) => event.id), ['r1']);
|
||||
|
||||
gate.complete([_reply('r1', 1000), _reply('r2', 2000)]);
|
||||
final refreshed = await container.read(threadRepliesProvider(args).future);
|
||||
expect(refreshed.map((event) => event.id), ['r1', 'r2']);
|
||||
});
|
||||
|
||||
test(
|
||||
'confirmation survives disposing the combined provider before its microtask',
|
||||
() async {
|
||||
final reply = _reply('r1', 1000);
|
||||
final query = Completer<List<NostrEvent>>();
|
||||
final fakeSession = _FakeRelaySession()..nextQueryGate = query;
|
||||
final container = ProviderContainer(
|
||||
overrides: [relaySessionProvider.overrideWith(() => fakeSession)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
container.read(threadLocalRepliesProvider(args).notifier).add(reply);
|
||||
container
|
||||
.read(pendingLocalMessagesProvider(args.channelId).notifier)
|
||||
.add(reply);
|
||||
|
||||
late ProviderSubscription<AsyncValue<List<NostrEvent>>> subscription;
|
||||
subscription = container.listen(threadRepliesWithLocalProvider(args), (
|
||||
_,
|
||||
next,
|
||||
) {
|
||||
if (next.value?.any((event) => event.id == reply.id) ?? false) {
|
||||
subscription.close();
|
||||
}
|
||||
});
|
||||
query.complete([reply]);
|
||||
await container.pump();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(container.read(threadLocalRepliesProvider(args)), isEmpty);
|
||||
expect(
|
||||
container.read(pendingLocalMessagesProvider(args.channelId)),
|
||||
isEmpty,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'mounted thread renders a reply missed while disconnected after reconnect',
|
||||
() async {
|
||||
final fakeSession = _FakeRelaySession()..replies = [_reply('r1', 1000)];
|
||||
final container = ProviderContainer(
|
||||
overrides: [relaySessionProvider.overrideWith(() => fakeSession)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final mountedThread = container.listen(
|
||||
threadRepliesWithLocalProvider(args),
|
||||
(_, _) {},
|
||||
);
|
||||
addTearDown(mountedThread.close);
|
||||
|
||||
await container.read(threadRepliesProvider(args).future);
|
||||
expect(mountedThread.read().value?.map((event) => event.id), ['r1']);
|
||||
|
||||
fakeSession.setStatus(SessionStatus.disconnected);
|
||||
await container.pump();
|
||||
fakeSession.replies = [_reply('r1', 1000), _reply('r2', 2000)];
|
||||
fakeSession.setStatus(SessionStatus.connected);
|
||||
await container.pump();
|
||||
await container.read(threadRepliesProvider(args).future);
|
||||
|
||||
expect(mountedThread.read().value?.map((event) => event.id), [
|
||||
'r1',
|
||||
'r2',
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test('reopening a disposed thread performs a fresh load', () async {
|
||||
final (container, fakeSession, subscription) = makeHarness([
|
||||
_reply('r1', 1000),
|
||||
]);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(threadRepliesProvider(args).future);
|
||||
final queriesAfterFirstLoad = fakeSession.queryCount;
|
||||
|
||||
// Close the page: the auto-disposed query is torn down…
|
||||
subscription.close();
|
||||
await container.pump();
|
||||
|
||||
// …so reopening loads fresh instead of serving a stale cache.
|
||||
fakeSession.replies = [_reply('r1', 1000), _reply('r2', 2000)];
|
||||
container.listen(threadRepliesProvider(args), (_, _) {});
|
||||
final reopened = await container.read(threadRepliesProvider(args).future);
|
||||
expect(reopened.map((event) => event.id), ['r1', 'r2']);
|
||||
expect(fakeSession.queryCount, queriesAfterFirstLoad + 1);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user