mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
<!-- 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) |  |  | <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>
89 lines
3.3 KiB
Dart
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;
|
|
}
|