diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index d99e332..a2c1bda 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -547,6 +547,7 @@ class ChatScreen extends HookConsumerWidget { : null, onHorizontalDragEnd: isSearchMode ? null : () => input.setReplyingTo(message), mentionDisplayName: resolveMentionDisplayName, + groupId: isGroupChat ? groupId : null, onRetry: !isSearchMode && isOwnMessage && message.deliveryStatus is DeliveryStatus_Failed ? () async { diff --git a/lib/screens/group_member_screen.dart b/lib/screens/group_member_screen.dart index 9849f77..a436356 100644 --- a/lib/screens/group_member_screen.dart +++ b/lib/screens/group_member_screen.dart @@ -12,6 +12,7 @@ import 'package:whitenoise/hooks/use_user_metadata.dart'; import 'package:whitenoise/l10n/l10n.dart'; import 'package:whitenoise/providers/account_pubkey_provider.dart'; import 'package:whitenoise/routes.dart'; +import 'package:whitenoise/screens/start_chat_screen.dart'; import 'package:whitenoise/theme.dart'; import 'package:whitenoise/utils/avatar_color.dart'; import 'package:whitenoise/utils/metadata.dart' show presentName; @@ -87,6 +88,21 @@ class GroupMemberScreen extends HookConsumerWidget { return null; }, [membersState.error]); + final didRedirect = useRef(false); + useEffect(() { + if (didRedirect.value) return null; + if (membersState.isLoading || membersState.error != null) return null; + if (membersState.members.contains(memberPubkey)) return null; + didRedirect.value = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + Routes.goBack(context); + if (!context.mounted) return; + StartChatScreen.show(context, userPubkey: memberPubkey); + }); + return null; + }, [membersState.isLoading, membersState.members]); + final isCurrentUserAdmin = membersState.admins.contains(accountPubkey); final isMemberAdmin = membersState.admins.contains(memberPubkey); final displayName = presentName(metadata) ?? memberPubkey.substring(0, 8); diff --git a/lib/screens/start_chat_screen.dart b/lib/screens/start_chat_screen.dart index 2a99b07..14d8e21 100644 --- a/lib/screens/start_chat_screen.dart +++ b/lib/screens/start_chat_screen.dart @@ -191,8 +191,8 @@ class StartChatScreen extends HookConsumerWidget { child: SafeArea( child: Column( children: [ - const Spacer(), WnSlate( + shrinkWrapContent: true, header: WnSlateNavigationHeader( title: context.l10n.startNewChat, onNavigate: () => Routes.goBack(context), diff --git a/lib/widgets/chat_message_bubble.dart b/lib/widgets/chat_message_bubble.dart index 1dd4c39..4cb3bc1 100644 --- a/lib/widgets/chat_message_bubble.dart +++ b/lib/widgets/chat_message_bubble.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:whitenoise/hooks/use_chat_messages.dart' show ChatMessageQuoteData; import 'package:whitenoise/l10n/l10n.dart'; +import 'package:whitenoise/routes.dart'; import 'package:whitenoise/screens/start_chat_screen.dart'; import 'package:whitenoise/src/rust/api/markdown.dart'; import 'package:whitenoise/src/rust/api/messages.dart'; @@ -38,6 +39,7 @@ class ChatMessageBubble extends StatelessWidget { final VoidCallback? onHorizontalDragEnd; final VoidCallback? onRetry; final String? Function(String hexPubkey)? mentionDisplayName; + final String? groupId; const ChatMessageBubble({ super.key, @@ -60,6 +62,7 @@ class ChatMessageBubble extends StatelessWidget { this.onHorizontalDragEnd, this.onRetry, this.mentionDisplayName, + this.groupId, }); ChatStatusType? get _deliveryStatusType { @@ -162,7 +165,11 @@ class ChatMessageBubble extends StatelessWidget { onNostrTap: (hrp, bech32) async { if (hrp == MarkdownNostrHrp.npub) { final hex = hexFromNpub(bech32); - if (hex != null && context.mounted) { + if (hex == null || !context.mounted) return; + final gid = groupId; + if (gid != null) { + Routes.pushToGroupMember(context, gid, hex); + } else { await StartChatScreen.show(context, userPubkey: hex); } return; diff --git a/test/screens/group_member_screen_test.dart b/test/screens/group_member_screen_test.dart index d95174c..d54a98f 100644 --- a/test/screens/group_member_screen_test.dart +++ b/test/screens/group_member_screen_test.dart @@ -5,6 +5,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart' show AsyncData; import 'package:flutter_test/flutter_test.dart'; import 'package:whitenoise/providers/auth_provider.dart'; import 'package:whitenoise/routes.dart'; +import 'package:whitenoise/screens/group_member_screen.dart'; +import 'package:whitenoise/screens/start_chat_screen.dart'; import 'package:whitenoise/src/rust/api/groups.dart'; import 'package:whitenoise/src/rust/api/metadata.dart'; import 'package:whitenoise/src/rust/frb_generated.dart'; @@ -726,5 +728,27 @@ void main() { expect(find.text('Add to group'), findsWidgets); }); }); + + group('non-member redirect', () { + testWidgets('redirects to StartChatScreen when the user is not in the group', ( + tester, + ) async { + _api.adminsList = [_testPubkey]; + await pumpGroupMemberScreen(tester, memberPubkey: testPubkeyD); + await tester.pumpAndSettle(); + + expect(find.byType(GroupMemberScreen), findsNothing); + expect(find.byType(StartChatScreen), findsOneWidget); + }); + + testWidgets('does not redirect when the user is a member', (tester) async { + _api.adminsList = [_testPubkey]; + await pumpGroupMemberScreen(tester, memberPubkey: _memberPubkey); + await tester.pumpAndSettle(); + + expect(find.byType(GroupMemberScreen), findsOneWidget); + expect(find.byType(StartChatScreen), findsNothing); + }); + }); }); } diff --git a/test/widgets/chat_message_bubble_test.dart b/test/widgets/chat_message_bubble_test.dart index 6652b38..f0e4c86 100644 --- a/test/widgets/chat_message_bubble_test.dart +++ b/test/widgets/chat_message_bubble_test.dart @@ -919,6 +919,42 @@ void main() { ); }); + testWidgets('npub mention in a group navigates to the group member screen', (tester) async { + await _mountBubbleWithRouter( + tester, + ChatMessageBubble( + message: withDoc( + const MarkdownDocument( + blocks: [ + MarkdownBlock.paragraph( + inlines: [ + MarkdownInline.nostrMention( + entity: MarkdownNostrEntity( + hrp: MarkdownNostrHrp.npub, + bech32: testNpubA, + ), + ), + ], + ), + ], + ), + ), + isOwnMessage: false, + groupId: testGroupId, + ), + ); + final span = tester.widget(find.byType(RichText).first).text as TextSpan; + final entity = _firstTextSpanWithRecognizer(span); + (entity.recognizer as TapGestureRecognizer).onTap!(); + await tester.pumpAndSettle(); + expect(launcher.calls, isEmpty); + expect(find.byType(StartChatScreen), findsNothing); + expect( + find.byKey(const ValueKey('group_member_route_${testGroupId}_$testPubkeyA')), + findsOneWidget, + ); + }); + testWidgets('whitenoise://chat/ tap navigates via GoRouter', (tester) async { await _mountBubbleWithRouter( tester, @@ -1075,6 +1111,16 @@ Future _mountBubbleWithRouter(WidgetTester tester, Widget bubble) async { body: const SizedBox(), ), ), + GoRoute( + name: 'groupMember', + path: '/group-member/:groupId/:memberPubkey', + builder: (_, state) => Scaffold( + key: ValueKey( + 'group_member_route_${state.pathParameters['groupId']}_${state.pathParameters['memberPubkey']}', + ), + body: const SizedBox(), + ), + ), ], ); await tester.pumpWidget(