From 1fe2e35fc1df41dd9719bae416a66b4668afc1e3 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 28 Oct 2026 06:38:55 -0700 Subject: [PATCH] fix(mobile): open Search and profile DMs as ordinary routes Search and the profile sheet pushed the opened DM with the default lookup placement, so while Solo groups loaded the route showed only a spinner for up to 2s. Pass the ordinary placement, as New message already does. Known Solo placement still takes over once groups load. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tom Brow --- .../features/profile/user_profile_sheet.dart | 5 +- mobile/lib/features/search/search_page.dart | 5 +- .../user_profile_sheet_message_test.dart | 111 ++++++++++++++++++ .../features/search/search_page_test.dart | 84 +++++++++++++ 4 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 mobile/test/features/profile/user_profile_sheet_message_test.dart diff --git a/mobile/lib/features/profile/user_profile_sheet.dart b/mobile/lib/features/profile/user_profile_sheet.dart index 2a39df54bc..387ce14718 100644 --- a/mobile/lib/features/profile/user_profile_sheet.dart +++ b/mobile/lib/features/profile/user_profile_sheet.dart @@ -49,7 +49,10 @@ void showUserProfileSheet( if (channel == null || !context.mounted) return; Navigator.of(context).push( MaterialPageRoute( - builder: (_) => ChannelDetailPage(channel: channel), + builder: (_) => ChannelDetailPage( + channel: channel, + placement: ChannelRoutePlacement.ordinary, + ), ), ); }); diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index e787e09867..78e2d2848b 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -587,7 +587,10 @@ class _PeopleSection extends ConsumerWidget { if (!context.mounted) return; await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => ChannelDetailPage(channel: channel), + builder: (_) => ChannelDetailPage( + channel: channel, + placement: ChannelRoutePlacement.ordinary, + ), ), ); }, diff --git a/mobile/test/features/profile/user_profile_sheet_message_test.dart b/mobile/test/features/profile/user_profile_sheet_message_test.dart new file mode 100644 index 0000000000..1ad80bd592 --- /dev/null +++ b/mobile/test/features/profile/user_profile_sheet_message_test.dart @@ -0,0 +1,111 @@ +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channel_detail_page.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/compose_bar.dart'; +import 'package:buzz/features/profile/user_profile_sheet.dart'; +import 'package:buzz/shared/me/me_sync.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + final peer = 'a' * 64; + + testWidgets('Message opens the DM without waiting on Solo', (tester) async { + final dm = Channel( + id: 'dm-peer', + name: 'Peer', + channelType: 'dm', + visibility: 'private', + description: '', + memberCount: 2, + createdBy: 'self', + createdAt: DateTime(2025), + isMember: true, + ); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final opened = >[]; + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + userCacheProvider.overrideWith( + () => _UserCache({ + peer: UserProfile(pubkey: peer, displayName: 'Peer'), + }), + ), + channelActionsProvider.overrideWith( + (ref) => _FakeDmActions(dm, opened), + ), + savedPrefsProvider.overrideWithValue(prefs), + // Solo organization is still loading. + meGroupsProvider.overrideWith((ref) => const Stream.empty()), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => showUserProfileSheet(context, peer), + child: const Text('Open profile'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open profile')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Message')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(opened, [ + [peer], + ]); + expect( + tester.widget(find.byType(ChannelDetailPage)).channel, + dm, + ); + // The DM is usable at once, not a placement spinner. + expect(find.byType(ComposeBar), findsOneWidget); + }); +} + +class _UserCache extends UserCacheNotifier { + _UserCache(this._initial); + + final Map _initial; + + @override + Map build() => _initial; + + @override + Future preload(List pubkeys) async => true; +} + +class _FakeDmActions implements ChannelActions { + _FakeDmActions(this.dm, this.opened); + + final Channel dm; + final List> opened; + + @override + Future openDm({ + required List pubkeys, + bool waitForChannelList = true, + }) async { + opened.add(pubkeys); + return dm; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/mobile/test/features/search/search_page_test.dart b/mobile/test/features/search/search_page_test.dart index 9810165914..11ee833fcb 100644 --- a/mobile/test/features/search/search_page_test.dart +++ b/mobile/test/features/search/search_page_test.dart @@ -1,6 +1,8 @@ import 'package:buzz/shared/widgets/buzz_agent_icon.dart'; import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channel_detail_page.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/compose_bar.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/message_content.dart'; import 'package:buzz/features/channels/small_avatar.dart'; @@ -12,12 +14,14 @@ import 'package:buzz/features/search/search_page.dart'; import 'package:buzz/features/search/search_provider.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/me/me_sync.dart'; import 'package:buzz/shared/widgets/concentric_sheet_surface.dart'; import 'package:buzz/shared/widgets/ios_glass_navigation_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../../helpers/widget_helpers.dart'; @@ -112,6 +116,67 @@ void main() { } } + testWidgets('a person result opens their DM without waiting on Solo', ( + tester, + ) async { + final dm = Channel( + id: 'dm-maya', + name: 'Maya', + channelType: 'dm', + visibility: 'private', + description: '', + memberCount: 2, + createdBy: 'test', + createdAt: DateTime(2025), + isMember: true, + ); + final opened = >[]; + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + searchProvider.overrideWith( + () => _FakeSearchNotifier( + const SearchState( + query: 'maya', + userResults: [ + DirectoryUser(pubkey: 'maya', displayName: 'Maya'), + ], + ), + ), + ), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const []), + ), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + channelsProvider.overrideWith(() => _FakeChannelsNotifier()), + channelActionsProvider.overrideWith( + (ref) => _FakeDmActions(dm, opened), + ), + savedPrefsProvider.overrideWithValue(prefs), + // Solo organization is still loading. + meGroupsProvider.overrideWith((ref) => const Stream.empty()), + ], + child: const SearchPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('search-person-row-maya'))); + await tester.pump(); + await tester.pump(); + + expect(opened, [ + ['maya'], + ]); + final page = tester.widget( + find.byType(ChannelDetailPage), + ); + expect(page.channel.id, dm.id); + // The DM is usable at once, not a placement spinner. + expect(find.byType(ComposeBar), findsOneWidget); + }); + testWidgets('iOS native filter press selects Channels and filters results', ( tester, ) async { @@ -933,6 +998,25 @@ class _FakeChannelsNotifier extends ChannelsNotifier { Future> build() async => channels; } +class _FakeDmActions implements ChannelActions { + _FakeDmActions(this.dm, this.opened); + + final Channel dm; + final List> opened; + + @override + Future openDm({ + required List pubkeys, + bool waitForChannelList = true, + }) async { + opened.add(pubkeys); + return dm; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + class _FakeUserCacheNotifier extends UserCacheNotifier { _FakeUserCacheNotifier(this.profile);