From a352ce1fd4607c0ffbd9fd228c5029aea803f59e Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 27 Oct 2026 21:50:16 -0700 Subject: [PATCH] fix(mobile): fail a fast DM open that leaves the DM hidden Reopening a hidden DM unhides it, but the relay accepts the open even when publishing that visibility snapshot fails. The fast open path now reads the caller's visibility snapshot alongside the DM's metadata and roster and fails if the DM is still hidden, so the sheet shows an error and Done can retry instead of opening a DM the sidebar keeps hidden. Signed-off-by: Tom Brow --- .../features/channels/channel_directory.dart | 26 +++++++++------- .../channels/channel_management_actions.dart | 15 +++++++++- .../channels/dm_open_latency_test.dart | 30 +++++++++++++++++++ 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/mobile/lib/features/channels/channel_directory.dart b/mobile/lib/features/channels/channel_directory.dart index 89e10abc87..8ac4720ced 100644 --- a/mobile/lib/features/channels/channel_directory.dart +++ b/mobile/lib/features/channels/channel_directory.dart @@ -255,21 +255,27 @@ Future> _fetchHiddenDmIds( String myPk, ) async { try { - final events = await session.fetchHistory(NostrFilters.hiddenDms(myPk)); - if (events.isEmpty) return const {}; - NostrEvent latest = events.first; - for (final event in events.skip(1)) { - if (event.createdAt > latest.createdAt) latest = event; - } - return { - for (final tag in latest.tags) - if (tag.length >= 2 && tag[0] == 'h') tag[1], - }; + return hiddenDmIdsFromEvents( + await session.fetchHistory(NostrFilters.hiddenDms(myPk)), + ); } catch (_) { return const {}; } } +/// The DM ids hidden by the newest of the caller's NIP-DV snapshots. +Set hiddenDmIdsFromEvents(List events) { + if (events.isEmpty) return const {}; + NostrEvent latest = events.first; + for (final event in events.skip(1)) { + if (event.createdAt > latest.createdAt) latest = event; + } + return { + for (final tag in latest.tags) + if (tag.length >= 2 && tag[0] == 'h') tag[1], + }; +} + Future> _fetchHuddleStarts( RelaySessionNotifier session, List parentChannelIds, diff --git a/mobile/lib/features/channels/channel_management_actions.dart b/mobile/lib/features/channels/channel_management_actions.dart index aba86dfb8d..6b2d37dd59 100644 --- a/mobile/lib/features/channels/channel_management_actions.dart +++ b/mobile/lib/features/channels/channel_management_actions.dart @@ -145,7 +145,20 @@ class ChannelActions { } if (waitForChannelList) return _refreshChannelsAndRead(channelId); unawaited(_ref.read(channelsProvider.notifier).refresh()); - return await findJoinedChannel(channelId) ?? + // Reopening a hidden DM unhides it, but the relay still accepts the open + // when that visibility snapshot fails. Check it here so the failure shows + // and Done can retry, rather than leaving the DM hidden from the sidebar. + final pk = _currentPubkey; + final hiddenRequest = pk == null + ? Future.value(const []) + : _session.fetchHistory(NostrFilters.hiddenDms(pk)); + final channelRequest = findJoinedChannel(channelId); + await Future.wait(eagerError: true, [hiddenRequest, channelRequest]); + _ensureCommunityValid(); + if (hiddenDmIdsFromEvents(await hiddenRequest).contains(channelId)) { + throw StateError('DM was opened but is still hidden'); + } + return await channelRequest ?? (throw StateError('DM was opened but is not visible yet')); } diff --git a/mobile/test/features/channels/dm_open_latency_test.dart b/mobile/test/features/channels/dm_open_latency_test.dart index fd0348f8c9..ae6f65064b 100644 --- a/mobile/test/features/channels/dm_open_latency_test.dart +++ b/mobile/test/features/channels/dm_open_latency_test.dart @@ -67,6 +67,16 @@ void main() { throwsStateError, ); + // A reopen whose unhide snapshot the relay failed to publish fails + // visibly instead of opening a DM the sidebar still hides. + session.member = true; + session.hidden = {_dmId}; + await expectLater( + actions.openDm(pubkeys: [_peer], waitForChannelList: false), + throwsStateError, + ); + session.hidden = {}; + // Hidden-DM resurfacing keeps waiting for the list that clears its // hidden state, so it cannot outrun its own retry coalescing. session.member = true; @@ -95,6 +105,7 @@ class _DmSession extends RelaySessionNotifier { _DmSession(this.self); final String self; bool member = true; + Set hidden = {}; void Function()? onRead; final published = []; Completer> stalledList = Completer(); @@ -140,6 +151,25 @@ class _DmSession extends RelaySessionNotifier { NostrFilter filter, { Duration timeout = const Duration(seconds: 8), }) async { + if (filter.kinds.single == EventKind.dmVisibility) { + expect(filter.tags, { + '#p': [self], + }); + return [ + NostrEvent( + id: 'visibility', + pubkey: 'relay', + createdAt: 1, + kind: EventKind.dmVisibility, + tags: [ + ['p', self], + for (final id in hidden) ['h', id], + ], + content: '', + sig: '', + ), + ]; + } expect(filter.tags, { '#d': [_dmId], });