mirror of
https://github.com/block/buzz.git
synced 2026-10-30 14:01:37 +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>
168 lines
6.0 KiB
Dart
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]);
|
|
}
|