diff --git a/mobile/lib/features/channels/channel_management_actions.dart b/mobile/lib/features/channels/channel_management_actions.dart index f8d4b18453..dd89bf7954 100644 --- a/mobile/lib/features/channels/channel_management_actions.dart +++ b/mobile/lib/features/channels/channel_management_actions.dart @@ -93,6 +93,7 @@ class ChannelActions { return Channel( id: channelId, name: details.name, + participantPubkeys: ChannelData.fromEvent(metadata).participantPubkeys, channelType: details.channelType, labels: details.labels, visibility: details.visibility, @@ -112,8 +113,15 @@ class ChannelActions { /// /// This submits a kind:41010 command event; the relay responds with an OK /// message whose content carries `response:{...}` containing the new - /// `channel_id`. - Future openDm({required List pubkeys}) async { + /// `channel_id`. With [waitForChannelList] false, the DM opens from its own + /// metadata and roster while the channel list reconciles in the background, + /// since a full reload can take many seconds on a large account or slow + /// connection. Callers that depend on fresh list state, such as hidden-DM + /// resurfacing, keep the default. + Future openDm({ + required List pubkeys, + bool waitForChannelList = true, + }) async { _ensureCommunityValid(); final result = await _signedEventRelay.submit( kind: 41010, @@ -126,7 +134,10 @@ class ChannelActions { if (channelId == null || channelId.isEmpty) { throw Exception('Relay did not return a DM channel id'); } - return _refreshChannelsAndRead(channelId); + if (waitForChannelList) return _refreshChannelsAndRead(channelId); + unawaited(_ref.read(channelsProvider.notifier).refresh()); + return await findJoinedChannel(channelId) ?? + (throw StateError('DM was opened but is not visible yet')); } Future addMembers({ diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index 70db7df670..113f3a227b 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:math'; diff --git a/mobile/lib/features/channels/channels_page/sheets.dart b/mobile/lib/features/channels/channels_page/sheets.dart index 0c08ff6bfb..e066959691 100644 --- a/mobile/lib/features/channels/channels_page/sheets.dart +++ b/mobile/lib/features/channels/channels_page/sheets.dart @@ -515,6 +515,7 @@ class _NewDirectMessageSheet extends HookConsumerWidget { .read(channelActionsProvider) .openDm( pubkeys: selectedUsers.value.map((user) => user.pubkey).toList(), + waitForChannelList: false, ); if (context.mounted) { Navigator.of(context).pop(channel); diff --git a/mobile/lib/features/profile/user_profile_sheet.dart b/mobile/lib/features/profile/user_profile_sheet.dart index 4706452e54..2a39df54bc 100644 --- a/mobile/lib/features/profile/user_profile_sheet.dart +++ b/mobile/lib/features/profile/user_profile_sheet.dart @@ -154,7 +154,7 @@ class UserProfileSheet extends HookConsumerWidget { try { final channel = await ref .read(channelActionsProvider) - .openDm(pubkeys: [pk]); + .openDm(pubkeys: [pk], waitForChannelList: false); if (!context.mounted) return; Navigator.of(context).pop(channel); } catch (_) { diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index fe5b5a74d9..e787e09867 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -583,7 +583,7 @@ class _PeopleSection extends ConsumerWidget { onResultSelected(); final channel = await ref .read(channelActionsProvider) - .openDm(pubkeys: [user.pubkey]); + .openDm(pubkeys: [user.pubkey], waitForChannelList: false); if (!context.mounted) return; await Navigator.of(context).push( MaterialPageRoute( diff --git a/mobile/test/features/channels/dm_open_latency_test.dart b/mobile/test/features/channels/dm_open_latency_test.dart new file mode 100644 index 0000000000..03f0285cda --- /dev/null +++ b/mobile/test/features/channels/dm_open_latency_test.dart @@ -0,0 +1,181 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test( + 'opening a DM finishes while the full channel list is stalled', + () async { + final keys = nostr.Keys.generate(); + final session = _DmSession(keys.public); + final container = ProviderContainer( + retry: (_, _) => null, + overrides: [ + relaySessionProvider.overrideWith(() => session), + myPubkeyProvider.overrideWithValue(keys.public), + ], + ); + addTearDown(container.dispose); + container.listen(channelsProvider, (_, _) {}); + await session.listStarted.future; + var valid = true; + final actions = container.read( + Provider( + (ref) => ChannelActions( + ref: ref, + session: session, + signedEventRelay: SignedEventRelay( + session: session, + nsec: keys.nsec, + ), + currentPubkey: keys.public, + isCommunityValid: () => valid, + ), + ), + ); + + final opened = await actions + .openDm(pubkeys: [_peer], waitForChannelList: false) + .timeout(const Duration(seconds: 2)); + expect(opened.id, _dmId); + expect(opened.isDm, isTrue); + expect(opened.isMember, isTrue); + expect(opened.participantPubkeys, [keys.public, _peer]); + expect(session.published.single.kind, 41010); + expect(session.published.single.tags, [ + ['p', _peer], + ]); + + // The sidebar still reconciles, in the background, once the stalled + // initial load settles. + session.stalledList.complete(const []); + await pumpEventQueue(); + expect(session.listLoads, 2); + session.stalledList = Completer(); + + // A DM the relay confirms but the caller cannot read fails visibly. + session.member = false; + await expectLater( + actions.openDm(pubkeys: [_peer], waitForChannelList: false), + throwsStateError, + ); + + // Hidden-DM resurfacing keeps waiting for the list that clears its + // hidden state, so it cannot outrun its own retry coalescing. + session.member = true; + var resurfaced = false; + unawaited( + actions.openDm(pubkeys: [_peer]).then((_) => resurfaced = true), + ); + await pumpEventQueue(); + expect(resurfaced, isFalse); + + // A community switch during the targeted read cancels the open. + session.onRead = () => valid = false; + await expectLater( + actions.openDm(pubkeys: [_peer], waitForChannelList: false), + throwsStateError, + ); + }, + ); +} + +const _dmId = 'dm-id'; +const _peer = + '1111111111111111111111111111111111111111111111111111111111111111'; + +class _DmSession extends RelaySessionNotifier { + _DmSession(this.self); + final String self; + bool member = true; + void Function()? onRead; + final published = []; + Completer> stalledList = Completer(); + final listStarted = Completer(); + int listLoads = 0; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + // Every full channel-list load starts with this membership query. + expect(filters.single.tags, { + '#p': [self], + }); + listLoads++; + if (!listStarted.isCompleted) listStarted.complete(); + return stalledList.future; + } + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + published.add(event); + return NostrEvent( + id: event.id, + pubkey: event.pubkey, + createdAt: event.createdAt, + kind: event.kind, + tags: event.tags, + content: 'response:${jsonEncode({'channel_id': _dmId})}', + sig: event.sig, + ); + } + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + expect(filter.kinds, [39000, 39002]); + expect(filter.tags, { + '#d': [_dmId], + }); + onRead?.call(); + return [ + NostrEvent( + id: 'metadata', + pubkey: 'relay', + createdAt: 1, + kind: 39000, + tags: [ + ['d', _dmId], + ['name', 'DM'], + ['t', 'dm'], + ['private'], + ['hidden'], + ['p', self], + ['p', _peer], + ], + content: '', + sig: '', + ), + NostrEvent( + id: 'roster', + pubkey: 'relay', + createdAt: 1, + kind: 39002, + tags: [ + ['d', _dmId], + if (member) ['p', self, '', 'member'], + ['p', _peer, '', 'member'], + ], + content: '', + sig: '', + ), + ]; + } +}