fix(mobile): stop inferring historical mentions from recipient tags

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-09 13:12:56 -04:00
parent acd841354a
commit 446f9b14f6
3 changed files with 138 additions and 85 deletions

View File

@@ -83,13 +83,12 @@ Map<String, Set<String>> renderedMentionBindings(
}
// Untagged qualified literals are recognition blockers, not bindings.
bindings.putIfAbsent(label, () => <String>{});
if (!keys.contains(key)) continue;
qualified.add((
label: label,
base: match.group(1)!.toLowerCase(),
key: key,
));
add(label, key);
if (keys.contains(key)) add(label, key);
bindings.putIfAbsent(match.group(1)!.toLowerCase(), () => <String>{});
}
final winning = mentionOccurrences(
@@ -98,17 +97,12 @@ Map<String, Set<String>> renderedMentionBindings(
).map((range) => range.label).toSet();
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() : <String>{};
// Recipient tags do not encode which literal selected a key. In DMs they
// also include non-mentioned recipients, and missing profiles can leave
// an unrelated singleton. Even current aliases cannot establish the
// historical plain target. Only the qualified literal supplies its key.
bindings[base] = <String>{};
}
return bindings;
}

View File

@@ -1,16 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/features/channels/message_content.dart';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/message_mention_pubkeys.dart';
import 'package:buzz/shared/mentions/mention_tags.dart';
import '../../helpers/widget_helpers.dart';
void main() {
testWidgets('DM recipients cannot supply a missing historical identity', (
tester,
) async {
final first = 'a' * 64, second = 'b' * 64, bob = 'c' * 64;
final recipients = messageMentionPubkeys(
channel: Channel(
id: 'dm',
name: 'DM',
channelType: 'dm',
visibility: 'private',
description: '',
createdBy: 'self',
createdAt: DateTime(2026),
memberCount: 4,
),
senderPubkey: 'self',
explicitMentions: [first, second],
dmRecipientPubkeys: [first, second, bob],
);
final keys = mentionedPubkeysFromTags([
for (final key in recipients) ['p', key],
]);
// MessageBubble omits profiles with no displayName. Bob is only a DM
// recipient, not the author-selected plain Scout.
final profiles = {second: 'Scout', bob: 'Bob'};
String? tapped;
await tester.pumpWidget(
WidgetHelpers.testable(
child: MessageContent(
content: '@Scout @Scout ($second)',
mentionNames: {
for (final key in keys)
if (profiles[key] != null) key: profiles[key]!,
},
onMentionTap: (key) => tapped = key,
),
),
);
await tester.pumpAndSettle();
if (find.text('Bob').evaluate().isNotEmpty) {
await tester.tap(find.text('Bob'));
}
expect(tapped, isNot(bob));
expect(find.text('Bob'), findsNothing);
expect(find.text('Scout'), findsNothing);
await tester.tap(find.text('Scout (bbbbbbbb…bbbb)'));
expect(tapped, second);
});
testWidgets('untagged qualifiers cannot become shorter clickable aliases', (
tester,
) async {
await tester.pumpWidget(
WidgetHelpers.testable(
child: MessageContent(
content: '@Scout (${'b' * 64})',
content: '@Scout @Scout (${'b' * 64})',
mentionNames: {'a' * 64: 'Scout'},
onMentionTap: (_) => fail('unbound text is not a profile target'),
),
@@ -42,7 +94,7 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('namesake chips resolve exact tagged keys, not tag order', (
testWidgets('only qualified namesake chips have an exact historical target', (
tester,
) async {
final semantics = tester.ensureSemantics();
@@ -59,54 +111,49 @@ void main() {
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Scout'));
expect(tapped, first);
expect(find.text('Scout'), findsNothing);
await tester.tap(find.text('Scout (bbbbbbbb…bbbb)'));
expect(tapped, second);
expect(find.bySemanticsLabel(RegExp('Scout.*$second')), findsOneWidget);
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,
testWidgets(
'historical plain namesakes stay unbound 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,
),
onMentionTap: (key) => tapped = key,
),
),
);
await tester.pumpAndSettle();
if (ambiguous) {
);
await tester.pumpAndSettle();
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);
}
await tester.tap(find.text('Scout (bbbbbbbb…bbbb)'));
expect(tapped, second);
await tester.tap(find.text('Alice'));
expect(tapped, sibling);
}
}
}
}
});
},
);
}

View File

@@ -29,39 +29,51 @@ void main() {
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},
test('multiple historical bases remain unbound despite current aliases', () {
final bindings = renderedMentionBindings(
'@Scout @Scout ($second) @Zed @Zed ($first)',
{
first: 'Old Zed',
second: 'Old Scout',
'c' * 64: 'Scout',
'd' * 64: 'Zed',
},
);
expect(bindings['scout'], isEmpty);
expect(bindings['zed'], isEmpty);
expect(bindings['scout ($second)'], {second});
expect(bindings['zed ($first)'], {first});
});
test('qualified identity does not authorize an inferred plain namesake', () {
for (final names in [
{'a': 'Scout', second: 'Scout'},
{second: 'Scout', 'a': 'Scout'},
]) {
final bindings = renderedMentionBindings(
'@Scout @Scout ($second)',
names,
);
expect(
renderedMentionBindings('@Scout ($second)', {
first: 'Scout',
})['scout ($second)'],
isEmpty,
);
expect(
renderedMentionBindings('@Old ($second)', {
second: 'New',
})['old ($second)'],
{second},
);
},
);
expect(bindings['scout'], isEmpty);
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},
);
});
}