Files
buzz/mobile/lib/features/channels/compose_bar/draft_lifecycle.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

168 lines
6.0 KiB
Dart

part of '../compose_bar.dart';
Future<void> _sendTextOnlyDraft({
required BuildContext context,
required _MarkdownEditingController controller,
required ObjectRef<Map<String, MentionCandidate>> mentionMap,
required ObjectRef<int> draftRevision,
required int submittedDraftRevision,
required FocusNode focusNode,
required VoidCallback clearComposer,
required Future<void> Function() addMentionedNonMembers,
required _ComposeDraftPayload payload,
required _OutgoingMentions outgoing,
required ComposeBarOnSend onSend,
required ScaffoldMessengerState? messenger,
}) async {
TextEditingValue? clearedDraftText;
Map<String, MentionCandidate>? clearedDraftMentions;
int? clearedDraftRevision;
void restoreClearedDraft() {
if (!context.mounted ||
clearedDraftText == null ||
clearedDraftMentions == null ||
clearedDraftRevision == null ||
draftRevision.value != clearedDraftRevision) {
return;
}
mentionMap.value
..clear()
..addAll(clearedDraftMentions);
controller.value = clearedDraftText;
focusNode.requestFocus();
}
try {
await addMentionedNonMembers();
// Clear before optimistic insertion so the outgoing row and draft never
// appear simultaneously during the send transition. If the user edited
// while membership changes were pending, preserve that newer draft.
if (context.mounted && draftRevision.value == submittedDraftRevision) {
clearedDraftText = controller.value;
clearedDraftMentions = Map<String, MentionCandidate>.of(mentionMap.value);
clearComposer();
clearedDraftRevision = draftRevision.value;
}
await onSend(
payload.content,
outgoing.pubkeys,
mediaTags: [...payload.mediaTags, ...outgoing.referenceTags],
);
} on StateError {
restoreClearedDraft();
_reportSendCancelledByCommunitySwitch(messenger);
} catch (error) {
// The caller runs unawaited, so surface publish failures and restore the
// sent draft unless the user has already started a new one.
restoreClearedDraft();
messenger?.showSnackBar(
SnackBar(content: Text(_composeSendErrorMessage(error))),
);
}
}
void _useComposeDraftLifecycle({
required ObjectRef<Map<String, MentionCandidate>> mentionMap,
required WidgetRef ref,
required _MarkdownEditingController controller,
required String draftKey,
required String channelId,
required String? threadHeadId,
required String draftIdentity,
required ObjectRef<int> draftRevision,
required ValueNotifier<List<_PendingAttachment>> attachments,
required ObjectRef<int> uploadGeneration,
required ObjectRef<UploadCancellationToken?> activeUploadCancellation,
required ValueNotifier<int> uploadingCount,
required ValueNotifier<bool> isSending,
required ValueNotifier<_AttachmentSurface> attachmentSurface,
required ValueNotifier<String?> uploadError,
required _IOSAttachmentPopoverController iosAttachmentPopover,
required VoidCallback onDraftIdentityChanged,
}) {
final lastDraftIdentity = useRef<String?>(null);
useEffect(() {
final identity = '$draftIdentity\u0000$draftKey';
final shouldHydrate = lastDraftIdentity.value != identity;
final identityChanged =
lastDraftIdentity.value != null && lastDraftIdentity.value != identity;
lastDraftIdentity.value = identity;
final saved = ref.read(composeDraftsProvider.notifier).draftFor(draftKey);
if (shouldHydrate) {
mentionMap.value
..clear()
..addAll({
for (final entry
in (saved?.mentionKeys ?? <String, String>{}).entries)
entry.key: MentionCandidate(
pubkey: entry.value,
displayName: entry.key,
requiresRevalidation: true,
),
});
}
if (identityChanged) {
draftRevision.value += 1;
onDraftIdentityChanged();
uploadGeneration.value += 1;
activeUploadCancellation.value?.cancel();
activeUploadCancellation.value = null;
uploadingCount.value = 0;
isSending.value = false;
attachmentSurface.value = _AttachmentSurface.closed;
uploadError.value = null;
unawaited(iosAttachmentPopover.dispose());
final staleAttachments = attachments.value;
attachments.value = const [];
unawaited(_deleteOwnedAttachments(staleAttachments));
controller.text = saved?.text ?? '';
} else if (saved != null && controller.text.isEmpty) {
controller.text = saved.text;
}
var lastPersistedText = controller.text;
var lastBindings = <String, String>{
for (final e in mentionMap.value.entries) e.key: e.value.pubkey,
};
void persistDraft() {
final text = controller.text;
if (text != lastPersistedText) {
// Prune before the atomic snapshot, not in a later editor listener.
// Code formatting hides a binding without deleting its literal.
final retained = mentionOccurrences(
text.replaceAll('`', ' '),
mentionMap.value.keys,
).map((range) => range.label).toSet();
// Whole-record taint has no literal to prune. Removing every @ is a
// safe reset; unrelated edits must not turn lost keys into name lookup.
mentionMap.value.removeWhere(
(label, _) =>
label.isEmpty ? !text.contains('@') : !retained.contains(label),
);
}
final bindings = <String, String>{
for (final e in mentionMap.value.entries) e.key: e.value.pubkey,
};
if (text == lastPersistedText && mapEquals(bindings, lastBindings)) {
return;
}
lastBindings = bindings;
lastPersistedText = text;
draftRevision.value += 1;
ref
.read(composeDraftsProvider.notifier)
.save(
key: draftKey,
channelId: channelId,
threadHeadId: threadHeadId,
text: text,
mentionKeys: bindings,
);
}
controller.addListener(persistDraft);
return () => controller.removeListener(persistDraft);
}, [controller, draftKey, draftIdentity, onDraftIdentityChanged]);
}