From 762def4036e77d18b7679cf5f99b363aa28c834a Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 27 Oct 2026 21:14:14 -0700 Subject: [PATCH] fix(mobile): open new DMs without waiting for a full channel reload openDm reloaded every membership, profile, and latest message before returning, so the New message sheet's spinner could run for many seconds on a large account or slow connection. Interactive opens (New message, Search, profile) now open the DM from its own metadata and roster and reconcile the channel list in the background, as Solo chat creation already does. Hidden-DM resurfacing keeps waiting for the list, which is what clears its hidden state. Signed-off-by: Tom Brow --- .../channels/channel_management_actions.dart | 17 +- .../channels/channel_management_provider.dart | 1 + .../channels/channels_page/sheets.dart | 1 + .../features/profile/user_profile_sheet.dart | 2 +- mobile/lib/features/search/search_page.dart | 2 +- .../channels/dm_open_latency_test.dart | 181 ++++++++++++++++++ 6 files changed, 199 insertions(+), 5 deletions(-) create mode 100644 mobile/test/features/channels/dm_open_latency_test.dart 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: '', + ), + ]; + } +}