diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 726528a23e6..71ba39d14ec 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -18,6 +18,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:nostr/nostr.dart' as nostr; import '../../shared/mentions/agent_identity_provider.dart'; +import '../../shared/mentions/mention_bindings.dart'; import '../../shared/huddle/huddle_session.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; diff --git a/mobile/lib/features/channels/compose_bar/agent_mention_labels.dart b/mobile/lib/features/channels/compose_bar/agent_mention_labels.dart index bc29d5bb468..ea17a611b51 100644 --- a/mobile/lib/features/channels/compose_bar/agent_mention_labels.dart +++ b/mobile/lib/features/channels/compose_bar/agent_mention_labels.dart @@ -1,10 +1,36 @@ part of '../compose_bar.dart'; Set _agentMentionLabels({ - required Iterable candidates, -}) { - return { - for (final candidate in candidates) - if (candidate.isAgent) candidate.label, + required Map bindings, +}) => { + for (final entry in bindings.entries) + if (entry.value.isAgent) entry.key, +}; + +List _resolveComposerMentions( + String text, + Map selected, + List members, +) { + final candidates = >{ + for (final e in selected.entries) e.key.toLowerCase(): [e.value], }; + final selectedNames = candidates.keys.toSet(); + for (final member in members) { + final label = member.label.toLowerCase(); + if (!selectedNames.contains(label)) (candidates[label] ??= []).add(member); + } + final winners = {}; + for (final range in mentionOccurrences(text, candidates.keys)) { + final identities = { + for (final c in candidates[range.label]!) c.pubkey.toLowerCase(): c, + }; + if (identities.length > 1) { + throw FormatException( + 'The mention @${range.label} is ambiguous. Choose a recipient from the mention picker.', + ); + } + winners.addAll(identities); + } + return winners.values.toList(); } diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index ca34ffd2a1b..75be337051f 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -220,6 +220,10 @@ class ComposeBar extends HookConsumerWidget { // mentions. Used to pass resolved pubkeys directly to onSend and to attach // selected non-member agents before the message is published. final mentionMap = useRef({}); + useEffect(() { + mentionMap.value.clear(); + return null; + }, [draftIdentity, draftKey]); // Channel autocomplete state ---------------------------------------------- final channelQuery = useState(null); @@ -244,9 +248,7 @@ class ComposeBar extends HookConsumerWidget { // owners so @mention suggestions show names ("managed by …" included). final relayAgents = ref.watch(agentDirectoryProvider).asData?.value; final agentOwners = ref.watch(agentOwnersProvider).asData?.value; - final agentMentionLabels = _agentMentionLabels( - candidates: mentionMap.value.values, - ); + final agentMentionLabels = _agentMentionLabels(bindings: mentionMap.value); final agentMentionLabelsKey = (agentMentionLabels.toList()..sort()).join( '\u0000', ); @@ -295,6 +297,15 @@ class ComposeBar extends HookConsumerWidget { final text = editingValue.text; final sel = editingValue.selection; final textChanged = text != previousValue.text; + if (textChanged) { + // Formatting/code edits may temporarily hide a binding without + // deleting its literal. Rendering and send extraction remain separate. + final retained = mentionOccurrences( + text.replaceAll('`', ' '), + mentionMap.value.keys, + ).map((range) => range.label).toSet(); + mentionMap.value.removeWhere((label, _) => !retained.contains(label)); + } // Broadcast typing indicator (throttled). if (textChanged && text.isNotEmpty) { @@ -382,7 +393,10 @@ class ComposeBar extends HookConsumerWidget { // Insert a selected mention into the text field. void insertMention(MentionCandidate candidate) { - final name = candidate.label; + final name = selectedMentionLabel(candidate.label, candidate.pubkey, { + for (final entry in mentionMap.value.entries) + entry.key: entry.value.pubkey, + }); // Track the resolved candidate so we can pass its pubkey and prepare // selected non-member agents at send time. mentionMap.value[name] = candidate; @@ -470,10 +484,27 @@ class ComposeBar extends HookConsumerWidget { final messenger = ScaffoldMessenger.maybeOf(context); // 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, - ]; + List selectedMentions; + try { + selectedMentions = _resolveComposerMentions( + text, + mentionMap.value, + buildMentionCandidates( + members: channelMembersForAutocomplete( + membersAsync: membersAsync, + sessionStatus: sessionStatus, + cachedMembers: cachedMembers, + ), + relayAgents: const [], + sharedChannelIds: const {}, + userCache: userCache, + ownerByAgentPubkey: agentOwners ?? const {}, + ), + ); + } on FormatException catch (error) { + messenger?.showSnackBar(SnackBar(content: Text(error.message))); + return; + } final outgoing = _OutgoingMentions(selectedMentions); final scan = await _scanNonMemberMentions( ref, diff --git a/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart b/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart index 2ae71ac110b..2862aad1f37 100644 --- a/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart +++ b/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart @@ -282,6 +282,16 @@ class _MarkdownEditingController extends TextEditingController { if (prefix.isNotEmpty) spans.add(TextSpan(text: prefix, style: style)); final label = match.group(2)!; + if (RegExp(r'\([0-9a-f]{64}\)').hasMatch(label)) { + spans.add( + TextSpan( + text: '@$label', + style: style.copyWith(color: context.colors.primary), + ), + ); + offset = match.end; + continue; + } spans.add( WidgetSpan( alignment: PlaceholderAlignment.baseline, diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index c9aa72599fb..dcf549b1af0 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -15,6 +15,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:video_player/video_player.dart'; import '../../shared/clipboard_utils.dart'; +import '../../shared/mentions/mention_bindings.dart'; import '../../shared/deeplink/deep_link.dart'; import '../../shared/deeplink/pending_deep_link_provider.dart'; import '../../shared/relay/relay.dart'; @@ -159,6 +160,7 @@ class MessageContent extends HookConsumerWidget { baseStyle ?? context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface); final resolvedMentionNames = mentionNames; + final mentionBindings = renderedMentionBindings(content, mentionNames); final resolvedAgentMentionPubkeys = { ...agentMentionPubkeys.map((pubkey) => pubkey.toLowerCase()), }; @@ -236,14 +238,15 @@ class MessageContent extends HookConsumerWidget { mentionBuf.write('`${mentionParts[i]}`'); } else { var segment = mentionParts[i]; - for (final name in resolvedMentionNames.values) { - if (name.contains(' ')) { - final normalizedName = _markdownMentionName(name); - segment = segment.replaceAllMapped( - RegExp('@${RegExp.escape(name)}', caseSensitive: false), - (m) => '@$normalizedName', - ); - } + for (final range in mentionOccurrences( + segment, + mentionBindings.keys, + ).reversed) { + segment = segment.replaceRange( + range.start, + range.end, + '@${_markdownMentionName(range.label)}', + ); } mentionBuf.write(segment); } @@ -286,6 +289,14 @@ class MessageContent extends HookConsumerWidget { inlineComponents: [ _MentionMd( mentionNames: resolvedMentionNames, + bindings: mentionBindings, + displayLabels: { + for (final range in mentionOccurrences( + content, + mentionBindings.keys, + )) + range.label: content.substring(range.start + 1, range.end), + }, agentMentionPubkeys: resolvedAgentMentionPubkeys, onMentionTap: onMentionTap, ), @@ -807,16 +818,20 @@ class _MessageCodeBlock extends HookWidget { } class _MentionMd extends InlineMd { + final Map> bindings; + final Map displayLabels; final Map mentionNames; final Set agentMentionPubkeys; final void Function(String pubkey)? onMentionTap; late final RegExp _exp = _buildPrefixPattern( prefix: '@', - knownNames: _mentionAliases(mentionNames.values), + knownNames: bindings.keys.map(_markdownMentionName), genericTokenPattern: r'[A-Za-z0-9_][A-Za-z0-9_\u00A0-]*', ); _MentionMd({ + required this.bindings, + required this.displayLabels, required this.mentionNames, required this.agentMentionPubkeys, this.onMentionTap, @@ -837,22 +852,25 @@ class _MentionMd extends InlineMd { } final name = raw.substring(1).replaceAll('\u00A0', ' ').toLowerCase(); - String? displayName; - String? pubkey; - for (final entry in mentionNames.entries) { - final entryName = entry.value.toLowerCase(); - final firstName = entryName.split(RegExp(r'\s+')).first; - if (entryName == name || firstName == name) { - displayName = entry.value; - pubkey = entry.key; - break; - } + final matches = bindings[name] ?? const {}; + final pubkey = matches.length == 1 ? matches.single : null; + if (bindings.containsKey(name) && matches.length != 1) { + return TextSpan(text: text, style: config.style); } + final displayName = name.contains(RegExp(r'\([0-9a-f]{64}\)')) + ? displayLabels[name] + : mentionNames[pubkey]; final isAgent = pubkey != null && agentMentionPubkeys.contains(pubkey.toLowerCase()); + final fullLabel = displayName ?? raw.substring(1); + final visibleLabel = fullLabel.replaceAllMapped( + RegExp(r'\(([0-9a-f]{64})\)'), + (m) => '(${m[1]!.substring(0, 8)}…${m[1]!.substring(60)})', + ); final pill = _MentionPill( - label: displayName ?? raw.substring(1), + label: visibleLabel, + semanticsLabel: fullLabel, isAgent: isAgent, textStyle: config.style, ); @@ -861,7 +879,7 @@ class _MentionMd extends InlineMd { alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, child: pubkey != null && onMentionTap != null - ? GestureDetector(onTap: () => onMentionTap!(pubkey!), child: pill) + ? GestureDetector(onTap: () => onMentionTap!(pubkey), child: pill) : pill, ); } @@ -869,11 +887,13 @@ class _MentionMd extends InlineMd { class _MentionPill extends StatelessWidget { final String label; + final String? semanticsLabel; final bool isAgent; final TextStyle? textStyle; const _MentionPill({ required this.label, + this.semanticsLabel, required this.isAgent, this.textStyle, }); @@ -920,7 +940,9 @@ class _MentionPill extends StatelessWidget { offset: const Offset(0, -Grid.quarter), child: Text('@', style: style), ), - Text(label, style: style), + Flexible( + child: Text(label, style: style, semanticsLabel: semanticsLabel), + ), ], ), ); @@ -928,15 +950,3 @@ class _MentionPill extends StatelessWidget { } String _markdownMentionName(String name) => name.replaceAll(' ', '\u00A0'); - -Iterable _mentionAliases(Iterable mentionNames) sync* { - for (final name in mentionNames) { - final trimmed = name.trim(); - if (trimmed.isEmpty) continue; - yield _markdownMentionName(trimmed); - final firstName = trimmed.split(RegExp(r'\s+')).first; - if (firstName.isNotEmpty) { - yield firstName; - } - } -} diff --git a/mobile/lib/shared/mentions/mention_bindings.dart b/mobile/lib/shared/mentions/mention_bindings.dart new file mode 100644 index 00000000000..56b12b2a9da --- /dev/null +++ b/mobile/lib/shared/mentions/mention_bindings.dart @@ -0,0 +1,112 @@ +/// Reserve an exact label without retargeting an earlier selection. +String selectedMentionLabel( + String name, + String pubkey, + Map bindings, +) { + final normalized = { + for (final e in bindings.entries) + e.key.toLowerCase(): e.value.toLowerCase(), + }; + bool conflicts(String label) => + normalized.containsKey(label.toLowerCase()) && + normalized[label.toLowerCase()] != pubkey.toLowerCase(); + if (!conflicts(name)) return name; + final qualified = '$name (${pubkey.toLowerCase()})'; + var label = qualified; + for (var suffix = 2; conflicts(label); suffix++) { + label = '$qualified $suffix'; + } + return label; +} + +/// Longest literal ranges win, including labels containing another @ sign. +/// Recognition precedes eligibility: ambiguous labels still block shorter ones. +List<({int start, int end, String label})> mentionOccurrences( + String text, + Iterable labels, +) { + final matches = {}; + for (final label in labels) { + if (label.isEmpty) continue; + // A qualifier and reservation suffix belong to the literal even when no + // candidate binds it. Never fall back to a shorter, different recipient. + final suffix = + RegExp(r' \([0-9a-f]{64}\)$', caseSensitive: false).hasMatch(label) + ? r'(?! (?:[2-9]|[1-9][0-9]+)(?=[\s,;.!?:)\]}*_]|$))' + : ''; + final pattern = RegExp( + '(?:^|\\s|[*_]{1,3}|\\|\\|)(@${RegExp.escape(label)})(?! \\([0-9a-f]{64}\\))$suffix(?=\\|\\||[\\s,;.!?:)\\]}*_]|\$)', + caseSensitive: false, + ); + for (final match in pattern.allMatches(text)) { + final start = match.end - match.group(1)!.length; + if (matches[start] == null || matches[start]!.end < match.end) { + matches[start] = (start: start, end: match.end, label: label); + } + } + } + final result = <({int start, int end, String label})>[]; + for (final match + in matches.values.toList()..sort((a, b) => a.start.compareTo(b.start))) { + if (result.isEmpty || match.start >= result.last.end) result.add(match); + } + return result; +} + +/// Reconstruct display bindings only from event-tagged identities, never text +/// alone. Qualified tagged labels survive profile renames; ambiguous aliases +/// remain unbound. Mirrors Desktop's resolveMentionProps. +Map> renderedMentionBindings( + String content, + Map names, +) { + final bindings = >{}; + void add(String label, String key) => + (bindings[label.toLowerCase()] ??= {}).add(key.toLowerCase()); + for (final entry in names.entries) { + add(entry.value, entry.key); + add(entry.value.split(RegExp(r'\s+')).first, entry.key); + } + final keys = names.keys.map((key) => key.toLowerCase()).toSet(); + final qualified = <({String label, String base, String key})>[]; + for (final match in RegExp( + r'@([^@\r\n]+) \(([0-9a-f]{64})\)(?: ((?:[1-9][0-9]+|[2-9])))?', + caseSensitive: false, + ).allMatches(content)) { + final key = match.group(2)!.toLowerCase(); + final label = match.group(0)!.substring(1).toLowerCase(); + if (!mentionOccurrences(content, [ + label, + ]).any((range) => range.start == match.start)) { + continue; + } + // Untagged qualified literals are recognition blockers, not bindings. + bindings.putIfAbsent(label, () => {}); + if (!keys.contains(key)) continue; + qualified.add(( + label: label, + base: match.group(1)!.toLowerCase(), + key: key, + )); + add(label, key); + } + final winning = mentionOccurrences( + content, + bindings.keys, + ).map((range) => range.label).toSet(); + for (final entry in bindings.entries) { + if (entry.value.length < 2) continue; + entry.value.removeAll( + qualified + .where( + (q) => + q.base == entry.key && + winning.contains(q.label) && + bindings[q.label]?.length == 1, + ) + .map((q) => q.key), + ); + } + return bindings; +} diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 23b19d95577..8f05172c966 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -30,6 +30,8 @@ import 'package:buzz/shared/widgets/anchored_popover_menu.dart'; import 'package:buzz/shared/widgets/mobile_tab_footer_backdrop.dart'; import 'package:shared_preferences/shared_preferences.dart'; +part 'compose_bar_test/exact_mention_tests.dart'; + final _pngBytes = Uint8List.fromList([ 0x89, 0x50, @@ -641,6 +643,7 @@ class _FakeChannelsNotifier extends ChannelsNotifier { } void main() { + exactMentionTests(); TestWidgetsFlutterBinding.ensureInitialized(); setUp(() async { diff --git a/mobile/test/features/channels/compose_bar_test/exact_mention_tests.dart b/mobile/test/features/channels/compose_bar_test/exact_mention_tests.dart new file mode 100644 index 00000000000..fed5600cf5c --- /dev/null +++ b/mobile/test/features/channels/compose_bar_test/exact_mention_tests.dart @@ -0,0 +1,106 @@ +part of '../compose_bar_test.dart'; + +void exactMentionTests() { + final first = 'a' * 64; + final second = 'b' * 64; + List members() => [ + for (final key in [first, second]) + ChannelMember( + pubkey: key, + displayName: 'Scout', + role: 'member', + joinedAt: DateTime(2025), + ), + ]; + testWidgets( + 'same-name picker selections retain exact recipients through removal', + (tester) async { + List? sent; + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + members: members(), + channels: [_makeCurrentChannel()], + onSend: (_, keys, {mediaTags = const []}) async { + sent = keys; + }, + ), + ); + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), '@'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Scout').first); + await tester.pumpAndSettle(); + final controller = tester + .widget(find.byType(TextField)) + .controller!; + expect(controller.text, '@Scout '); + await tester.enterText(find.byType(TextField), '@Scout @'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Scout').last); + await tester.pumpAndSettle(); + expect(controller.text, '@Scout @Scout ($second) '); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pumpAndSettle(); + expect(sent, [first, second]); + await tester.enterText(find.byType(TextField), '@'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Scout').first); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '@Scout @'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Scout').last); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '@Scout ($second) '); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pumpAndSettle(); + expect(sent, [second]); + }, + ); + + testWidgets('unbound qualified text cannot notify a shorter member alias', ( + tester, + ) async { + List? sent; + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + members: [members().first], + onSend: (_, keys, {mediaTags = const []}) async => sent = keys, + ), + ); + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), '@Scout ($second)'); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pumpAndSettle(); + expect(sent, isEmpty); + }); + + testWidgets('ambiguous typed names fail visibly without clearing the draft', ( + tester, + ) async { + var sent = false; + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + members: members(), + onSend: (_, _, {mediaTags = const []}) async { + sent = true; + }, + ), + ); + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), '@Scout hello'); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pumpAndSettle(); + expect(sent, isFalse); + expect( + tester.widget(find.byType(TextField)).controller!.text, + '@Scout hello', + ); + expect(find.textContaining('is ambiguous'), findsOneWidget); + }); +} diff --git a/mobile/test/features/channels/message_content_exact_mentions_test.dart b/mobile/test/features/channels/message_content_exact_mentions_test.dart new file mode 100644 index 00000000000..078ff104bb7 --- /dev/null +++ b/mobile/test/features/channels/message_content_exact_mentions_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/features/channels/message_content.dart'; +import '../../helpers/widget_helpers.dart'; + +void main() { + testWidgets('untagged qualifiers cannot become shorter clickable aliases', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + child: MessageContent( + content: '@Scout (${'b' * 64})', + mentionNames: {'a' * 64: 'Scout'}, + onMentionTap: (_) => fail('unbound text is not a profile target'), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Scout'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('qualified chips wrap in narrow layouts at large text sizes', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + WidgetHelpers.testable( + child: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(2)), + child: MessageContent( + content: '@Scout (${'b' * 64})', + mentionNames: {'b' * 64: 'Scout'}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + testWidgets('namesake chips resolve exact tagged keys, not tag order', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final first = 'a' * 64; + final second = 'b' * 64; + String? tapped; + await tester.pumpWidget( + WidgetHelpers.testable( + child: MessageContent( + content: '@Scout @Scout ($second)', + mentionNames: {second: 'Scout', first: 'Scout'}, + onMentionTap: (key) => tapped = key, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Scout')); + expect(tapped, first); + await tester.tap(find.text('Scout (bbbbbbbb…bbbb)')); + expect(tapped, second); + expect(find.bySemanticsLabel(RegExp('Scout.*$second')), findsOneWidget); + expect(tester.takeException(), isNull); + semantics.dispose(); + }); +} diff --git a/mobile/test/shared/mentions/mention_bindings_test.dart b/mobile/test/shared/mentions/mention_bindings_test.dart new file mode 100644 index 00000000000..e73a64e5d73 --- /dev/null +++ b/mobile/test/shared/mentions/mention_bindings_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/shared/mentions/mention_bindings.dart'; + +void main() { + final first = 'a' * 64; + final second = 'b' * 64; + test('qualification is case-insensitive and collision-safe', () { + final bindings = {'Scout': first, 'Scout ($second)': first}; + expect(selectedMentionLabel('scout', first, bindings), 'scout'); + expect( + selectedMentionLabel('Scout', second, bindings), + 'Scout ($second) 2', + ); + }); + test('longest occurrences block shorter and interior recipients', () { + expect( + mentionOccurrences('@Scout ($second) 2', [ + 'Scout', + 'Scout ($second)', + 'Scout ($second) 2', + ]).single.label, + 'Scout ($second) 2', + ); + expect(mentionOccurrences('@A @B', ['A @B', 'B']).single.label, 'A @B'); + expect(mentionOccurrences('mail@Scout', ['Scout']), isEmpty); + expect(mentionOccurrences('@Scout ($second)', ['Scout']), isEmpty); + expect( + mentionOccurrences('@Scout ($second) 2', ['Scout ($second)']), + isEmpty, + ); + }); + test( + 'tagged qualified identity narrows a namesake independently of tag order', + () { + for (final names in [ + {'a': 'Scout', second: 'Scout'}, + {second: 'Scout', 'a': 'Scout'}, + ]) { + final bindings = renderedMentionBindings( + '@Scout @Scout ($second)', + names, + ); + expect(bindings['scout'], {'a'}); + expect(bindings['scout ($second)'], {second}); + } + expect( + renderedMentionBindings('@Scout', { + first: 'Scout', + second: 'Scout', + })['scout'], + {first, second}, + ); + expect( + renderedMentionBindings('@Scout ($second)', { + first: 'Scout', + })['scout ($second)'], + isEmpty, + ); + expect( + renderedMentionBindings('@Old ($second)', { + second: 'New', + })['old ($second)'], + {second}, + ); + }, + ); +}