Files
buzz/mobile/lib/shared/mentions/mention_bindings.dart
Logan Johnson d074576871 fix(mobile): bind same-name mentions to exact selected identities (#7385)
<!-- Draft PR body for #7385 · fix(mobile): bind same-name mentions to
exact selected identities · https://github.com/block/buzz/pull/7385 -->

🤖

## Summary

If two people in a channel share a display name, mobile couldn't tell
them apart in mentions: picking the second could overwrite the first's
selection, a rendered mention linked whichever same-name person matched
first, and a later rename or a shorter name could re-bind the text to
the wrong recipient. This PR binds every mention to the exact selected
identity:

- Each same-name selection keeps its own recipient instead of
overwriting by name; conflicting picks get a qualified label like `Name
(key…)`.
- The longest matching label wins, so a shorter or interior name can
never claim part of a longer one and steal its identity.
- Rendering resolves recipients by the signed identity key from the
event's tags — never from message text alone — so qualified labels stay
correct regardless of tag order and survive later renames; an untagged
ambiguous label blocks shorter mentions instead of silently re-binding.

Ports the landed Desktop exact-recipient behavior (see
`docs/mention-editor.md`).

### Related issue

- Fixes: N/A. No mobile issue; Desktop's landed exact-recipient fixes
are the reference this ports.
- Independent base (`main`). #7387 (child) persists these exact
selections in saved drafts.
- Landing note: branches in this series overlap in the composer — when
rebasing, keep exact/durable mention bindings, the explicit
invite/reference-only choice, the account/visit/revision fences, and
authorization before membership preparation and publication; don't
resolve conflicts by taking either side wholesale.
- Draft — not requesting merge yet; the security advisory run for this
range timed out without results (no verdict).

### Testing

- Regressions cover same-name collisions, prefix/overlap, removal, tag
order, and renames.
- At `acd841354a692243f1cb4c04059baad42f1d2abb`: `just mobile-check` and
full `just mobile-test` pass (2,082 tests). The earlier full `just ci`
receipt linked below is reused only for unchanged non-mobile
code/tooling, not claimed as a rerun at this head.
- Previous-head evidence: `just mobile-check`, the full mobile test
suite, and full local `just ci` all pass — receipts in the [exact-head
evidence
comment](https://github.com/block/buzz/pull/7385#issuecomment-5574635836).
- Verification is widget-test level; no native device or simulator run
is claimed.

To see it: mention two teammates with the same display name — both stay
distinct, the second shows a qualified label, and the rendered message
keeps both correct even after either renames.

### Screenshots

Flutter production-widget test renders — not native-device screenshots
or acceptance captures.

| Scenario | Before | After |
|---|---|---|
| Qualified mention chip for same-name recipients, at 200% text scale
(deliberate stress fixture) | ![Before: the short chip is followed by
the distinguishing key spilling out as raw
text](https://github.com/user-attachments/assets/f1e40acc-24f1-47f7-b2f7-e3ba2348c169)
| ![After: the full qualified label wraps inside the bounded chip,
keeping the @ and robot glyph
visible](https://github.com/user-attachments/assets/b949e44f-e3f6-4c72-98ff-68139b2e1ab3)
|

<details>
<summary>Capture provenance</summary>

Rendered by the Flutter widget engine in a `flutter test` run
(production widgets, production theme; no device or simulator). Before:
this PR's declared base `3c7f288c60d67df78577b237e27c3dfc8831aaa1`.
After: its head `39afd73b0adfde14164f4b10dbd089cb498312b6`.

</details>

Signed-off-by: Logan Johnson <loganj@squareup.com>
2026-09-11 11:19:17 -04:00

89 lines
3.3 KiB
Dart

/// Reserve an exact label without retargeting an earlier selection.
String selectedMentionLabel(
String name,
String pubkey,
Map<String, String> 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<String> labels,
) {
final matches = <int, ({int start, int end, String label})>{};
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;
}
/// Bind tagged qualifiers and ordinary aliases, never historical namesakes.
Map<String, Set<String>> renderedMentionBindings(
String content,
Map<String, String> names, [
Iterable<String> signedPubkeys = const [],
]) {
final bindings = <String, Set<String>>{};
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 = signedPubkeys.map((key) => key.toLowerCase()).toSet();
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;
}
// Text can deny historical aliases without a profile, never authorize keys.
bindings[label] = keys.contains(key) ? {key} : <String>{};
// Recipient tags/current names cannot establish the historical plain owner.
bindings[match.group(1)!.toLowerCase()] = <String>{};
}
return bindings;
}