Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 30 additions & 19 deletions mobile/lib/features/channels/channel_messages_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
if (authoritative && event.threadReference.parentId == null) {
_confirmLocalMessages([event.id]);
}
_invalidateThreadReplies(event);
if (_usingChannelWindow) {
_handleWindowLiveEvent(event);
} else {
Expand All @@ -232,30 +233,40 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
state = AsyncData(flattened);
}

/// Refetches an open thread when a live reply arrives.
///
/// Thread replies are loaded by a one-shot future. Invalidate that future
/// before choosing a history path so both the channel-window and websocket
/// fallback paths refresh the open thread.
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),
),
);
}
if (!_isBroadcastReply(event)) return false;
}
if (thread?.parentId != null && !_isBroadcastReply(event)) return false;
// Thread summaries are neither a timeline row nor an aux event, but they are
// how the root's "N replies" row learns a reply landed — a reply itself
// never reaches the main timeline. Dropping them here meant the count only
Expand Down
50 changes: 50 additions & 0 deletions mobile/test/features/channels/channel_messages_provider_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,56 @@ 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');
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(
'thread live echo keeps ownership until the authoritative refetch succeeds',
() async {
Expand Down