diff --git a/mobile/lib/shared/mentions/mention_bindings.dart b/mobile/lib/shared/mentions/mention_bindings.dart index 56b12b2a9d..0a95ece9f4 100644 --- a/mobile/lib/shared/mentions/mention_bindings.dart +++ b/mobile/lib/shared/mentions/mention_bindings.dart @@ -90,23 +90,25 @@ Map> renderedMentionBindings( key: key, )); add(label, key); + bindings.putIfAbsent(match.group(1)!.toLowerCase(), () => {}); } 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), - ); + final claimed = qualified.where((q) => winning.contains(q.label)).toList(); + final bases = claimed.map((q) => q.base).where(winning.contains).toSet(); + final remaining = keys.difference(claimed.map((q) => q.key).toSet()); + // Ordinary sibling mentions claim their own identities before recovering a + // historical namesake. Current aliases must not retarget that namesake. + for (final label in winning.difference(bases)) { + final candidates = bindings[label]!; + if (candidates.length == 1) remaining.removeAll(candidates); + } + for (final base in bases) { + // Multiple historical labels or leftover tagged keys cannot be paired + // deterministically. Leave them as non-clickable references, not guesses. + bindings[base] = bases.length == 1 ? remaining.toSet() : {}; } return bindings; } diff --git a/mobile/test/features/channels/message_content_exact_mentions_test.dart b/mobile/test/features/channels/message_content_exact_mentions_test.dart index 078ff104bb..32f1bac1f9 100644 --- a/mobile/test/features/channels/message_content_exact_mentions_test.dart +++ b/mobile/test/features/channels/message_content_exact_mentions_test.dart @@ -67,4 +67,46 @@ void main() { expect(tester.takeException(), isNull); semantics.dispose(); }); + testWidgets('historical namesakes preserve targets with ordinary siblings', ( + tester, + ) async { + final first = 'a' * 64, second = 'b' * 64, sibling = 'c' * 64; + for (final firstName in ['Scout', 'Renamed Scout', first]) { + for (final secondName in ['Scout', 'Renamed Scout', second]) { + for (final reverse in [false, true]) { + for (final ambiguous in [false, true]) { + final entries = { + first: firstName, + second: secondName, + sibling: 'Alice', + if (ambiguous) 'd' * 64: 'Unknown', + }.entries.toList(); + String? tapped; + await tester.pumpWidget( + WidgetHelpers.testable( + child: MessageContent( + content: '@Scout @Scout ($second) @Alice', + mentionNames: Map.fromEntries( + reverse ? entries.reversed : entries, + ), + onMentionTap: (key) => tapped = key, + ), + ), + ); + await tester.pumpAndSettle(); + if (ambiguous) { + expect(find.text(firstName), findsNothing); + } else { + await tester.tap(find.text(firstName)); + expect(tapped, first); + } + await tester.tap(find.text('Scout (bbbbbbbb…bbbb)')); + expect(tapped, second); + await tester.tap(find.text('Alice')); + expect(tapped, sibling); + } + } + } + } + }); }