From fe71ce99c038536ec79b3b73ab1ac27e22a86f10 Mon Sep 17 00:00:00 2001 From: IcantCode1010 <86989251+IcantCode1010@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:12:29 -0400 Subject: [PATCH] fix-mobile-live-thread-replies Signed-off-by: IcantCode1010 <86989251+IcantCode1010@users.noreply.github.com> --- .../channels/channel_messages_provider.dart | 49 +++++++++++------- .../channel_messages_provider_test.dart | 50 +++++++++++++++++++ 2 files changed, 80 insertions(+), 19 deletions(-) diff --git a/mobile/lib/features/channels/channel_messages_provider.dart b/mobile/lib/features/channels/channel_messages_provider.dart index fbcbca8956..c5c0b446a5 100644 --- a/mobile/lib/features/channels/channel_messages_provider.dart +++ b/mobile/lib/features/channels/channel_messages_provider.dart @@ -213,6 +213,7 @@ class ChannelMessagesNotifier extends Notifier>> { if (authoritative && event.threadReference.parentId == null) { _confirmLocalMessages([event.id]); } + _invalidateThreadReplies(event); if (_usingChannelWindow) { _handleWindowLiveEvent(event); } else { @@ -232,30 +233,40 @@ class ChannelMessagesNotifier extends Notifier>> { 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 diff --git a/mobile/test/features/channels/channel_messages_provider_test.dart b/mobile/test/features/channels/channel_messages_provider_test.dart index 4f4efdce1f..cf944aa1d3 100644 --- a/mobile/test/features/channels/channel_messages_provider_test.dart +++ b/mobile/test/features/channels/channel_messages_provider_test.dart @@ -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'), + [], + [ + _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 {