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);