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], });