diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 7560f998f3..3a44114447 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -432,11 +432,16 @@ class ComposeBar extends HookConsumerWidget { return; } - // Extract pubkeys for mentions present in the final text. - final selectedMentions = [ - for (final entry in mentionMap.value.entries) - if (hasMention(text, entry.key)) entry.value, - ]; + // Mention candidates present in the final text — picked from the + // suggestion list or hand-typed member names (see + // _selectedMentionCandidates in compose_bar/helpers.dart). + final selectedMentions = _selectedMentionCandidates( + text: text, + picked: mentionMap.value, + members: ref.read( + mentionCandidatesProvider((channelId: channelId, query: '')), + ), + ); final pubkeys = LinkedHashSet.from( selectedMentions.map((candidate) => candidate.pubkey.toLowerCase()), ).toList(); diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index 145d28fb44..ba57d2b487 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -90,6 +90,38 @@ void _insertTriggerAtCursor( focusNode.requestFocus(); } +/// Mention candidates referenced by [text]: suggestion-picked entries first, +/// then hand-typed channel-member names that were never picked — mirrors +/// desktop's `extractMentionPubkeys` member scan, so a typed `@Name` still +/// notifies its target. Picked entries keep precedence for a name, and the +/// typed scan is members-only, so it can never trigger the non-member invite +/// prompt on its own. +List _selectedMentionCandidates({ + required String text, + required Map picked, + required Iterable members, +}) { + final selected = [ + for (final entry in picked.entries) + if (hasMention(text, entry.key)) entry.value, + ]; + final pickedNames = { + for (final name in picked.keys) name.trim().toLowerCase(), + }; + final pickedPubkeys = { + for (final candidate in picked.values) candidate.pubkey.toLowerCase(), + }; + for (final candidate in members) { + if (!candidate.isMember) continue; + if (pickedPubkeys.contains(candidate.pubkey.toLowerCase())) continue; + final name = candidate.displayName?.trim(); + if (name == null || name.isEmpty) continue; + if (pickedNames.contains(name.toLowerCase())) continue; + if (hasMention(text, name)) selected.add(candidate); + } + return selected; +} + bool hasMention(String text, String name) { final pattern = RegExp( '(?:^|\\s|[*_]{1,3}|\\|\\|)@${RegExp.escape(name)}(?=\\|\\||[\\s,;.!?:)\\]}*_]|\$)', diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 919d4039eb..5f13930969 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -2289,6 +2289,54 @@ void main() { ]); }); + testWidgets('send resolves a hand-typed member mention without a ' + 'suggestion tap', (tester) async { + final signer = nostr.Keys.generate(); + const memberPubkey = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + String? sentContent; + List sentMentionPubkeys = const []; + + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(signer.nsec), + currentPubkey: signer.public, + members: [ + ChannelMember( + pubkey: memberPubkey, + role: 'member', + joinedAt: DateTime(2024), + displayName: 'Fable', + ), + ], + channels: [_makeCurrentChannel()], + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async { + sentContent = content; + sentMentionPubkeys = mentionPubkeys; + }, + ), + ); + + await _expandComposer(tester); + // Type the whole message in one edit — the mention never goes through + // the suggestion list, mirroring a user who types the name by hand. + await tester.enterText( + find.byType(TextField), + 'hey @Fable are you there', + ); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pumpAndSettle(); + + expect(sentContent, 'hey @Fable are you there'); + expect(sentMentionPubkeys, [memberPubkey]); + }); + testWidgets( 'renders chips only for selected agents outside code and composition', (tester) async {