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:
Wes
2026-08-24 10:59:58 -06:00
committed by GitHub
parent 26f4c3ed30
commit 01091c15a1
7 changed files with 597 additions and 72 deletions

View 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);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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', () {

View 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);
});
}