diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index 1af27b58ef2..4a27ee671a6 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -78,6 +78,7 @@ export default defineConfig({
"**/composer-tooltip-dismiss.spec.ts",
"**/mentions.spec.ts",
"**/mention-spacing.spec.ts",
+ "**/mention-clipboard.spec.ts",
"**/cloud-provenance.spec.ts",
"**/team-mentions.spec.ts",
"**/persistent-agent-audience.spec.ts",
diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx
index 03fb365ebdd..cb663f9174e 100644
--- a/desktop/src/features/forum/ui/ForumComposer.tsx
+++ b/desktop/src/features/forum/ui/ForumComposer.tsx
@@ -9,10 +9,8 @@ import { useComposerFocusOwnership } from "@/features/messages/lib/useComposerFo
import { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
import { isMentionCodeContext } from "@/features/messages/lib/mentionCodeContext";
import { useMentions } from "@/features/messages/lib/useMentions";
-import {
- hasMentionClipboardHtml,
- normalizeMentionClipboardHtml,
-} from "@/features/messages/lib/normalizeMentionClipboard";
+import { hasMentionClipboardHtml } from "@/features/messages/lib/normalizeMentionClipboard";
+import { handleMentionClipboardPaste } from "@/features/messages/lib/mentionClipboardPaste";
import {
type LinkSelectionInfo,
useRichTextEditor,
@@ -121,6 +119,7 @@ export function ForumComposer({
mentionNames: mentions.knownNames,
channelNames: channelLinks.knownChannelNames,
messageLinkChannels: channelLinks.channels,
+ getMentionIdentities: mentions.getMentionIdentities,
onSubmit: () => submitMessageRef.current(),
isAutocompleteOpen: isAutocompleteOpenRef,
onEditLink: (info) => onEditLinkRef.current?.(info),
@@ -242,6 +241,9 @@ export function ForumComposer({
channelLinks.clearChannels();
setIsEmojiPickerOpen(false);
try {
+ // A pasted mention's identity check can still be in flight; extracting
+ // first would publish the label with no `p` tag. Bounded internally.
+ await mentions.settlePendingMentionBindings();
const pubkeys = await mentions.revalidateMentionPubkeys(
mentions.extractMentionPubkeys(trimmed),
);
@@ -292,6 +294,7 @@ export function ForumComposer({
mentions.cancelMentionAutocomplete,
mentions.extractMentionPubkeys,
mentions.revalidateMentionPubkeys,
+ mentions.settlePendingMentionBindings,
mentions.clearMentions,
channelLinks.clearChannels,
richText.clearContent,
@@ -359,6 +362,10 @@ export function ForumComposer({
// ── Media paste ─────────────────────────────────────────────────────
const uploadFileRef = React.useRef(media.uploadFile);
uploadFileRef.current = media.uploadFile;
+ const bindMentionIdentitiesRef = React.useRef(
+ mentions.bindPastedMentionIdentities,
+ );
+ bindMentionIdentitiesRef.current = mentions.bindPastedMentionIdentities;
React.useEffect(() => {
if (!richText.editor) return;
@@ -379,12 +386,15 @@ export function ForumComposer({
return true;
}
- const html = event.clipboardData?.getData("text/html");
- if (html && hasMentionClipboardHtml(html)) {
- const cleanHtml = normalizeMentionClipboardHtml(html);
- event.preventDefault();
- _view.pasteHTML(cleanHtml);
- return true;
+ const clipboardData = event.clipboardData;
+ const html = clipboardData?.getData("text/html");
+ if (clipboardData && html && hasMentionClipboardHtml(html)) {
+ return handleMentionClipboardPaste({
+ bindMentionIdentities: bindMentionIdentitiesRef.current,
+ clipboardData,
+ preventDefault: () => event.preventDefault(),
+ view: _view,
+ });
}
return false;
diff --git a/desktop/src/features/forum/ui/ForumThreadPanel.tsx b/desktop/src/features/forum/ui/ForumThreadPanel.tsx
index 0abd6a0d842..b3352d4f2a0 100644
--- a/desktop/src/features/forum/ui/ForumThreadPanel.tsx
+++ b/desktop/src/features/forum/ui/ForumThreadPanel.tsx
@@ -1,6 +1,7 @@
import { ArrowLeft, MessageSquare } from "lucide-react";
import * as React from "react";
+import { handleTimelineMentionCopy } from "@/features/messages/lib/timelineMentionCopy";
import {
resolveUserLabel,
type UserProfileLookup,
@@ -238,6 +239,7 @@ export function ForumThreadPanel({
{postsQuery.isLoading ? (
diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx
index 574491dd4ac..0360c1d9ad0 100644
--- a/desktop/src/features/home/ui/InboxDetailPane.tsx
+++ b/desktop/src/features/home/ui/InboxDetailPane.tsx
@@ -42,6 +42,7 @@ import {
hasRenderedVideoAttachment,
} from "@/features/messages/lib/videoReviewContext";
import { getThreadReference } from "@/features/messages/lib/threading";
+import { handleTimelineMentionCopy } from "@/features/messages/lib/timelineMentionCopy";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
import { useAnchoredScroll } from "@/features/messages/ui/useAnchoredScroll";
import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding";
@@ -694,6 +695,11 @@ function InboxMessageDetailPane({
aria-busy={isThreadContextLoading}
className="-mt-13 min-h-0 flex-1 overflow-y-auto overscroll-contain pb-32 pt-13 [overflow-anchor:none]"
data-testid="home-inbox-detail-scroll"
+ // Selection copy across a rendered mention chip: restores the sigil
+ // and the identity sidecar the browser's default copy would drop.
+ // Covers only the messages — the composer is a sibling overlay, so
+ // its own copy handler is untouched.
+ onCopy={handleTimelineMentionCopy}
onScroll={onScroll}
ref={scrollContainerRef}
>
@@ -768,6 +774,7 @@ function InboxMessageDetailPane({
onEdit={canEditMessage ? handleSelectEditTarget : undefined}
onSelectReplyTarget={handleSelectReplyTarget}
onToggleReaction={onToggleReaction}
+ profiles={profiles}
showUnreadBoundary={hasUnreadBoundary}
videoReviewCommentRootId={videoReviewPresentation.commentRootIdsByMessageId.get(
message.id,
diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx
index b706616fd2d..5377410ca07 100644
--- a/desktop/src/features/home/ui/InboxMessageRow.tsx
+++ b/desktop/src/features/home/ui/InboxMessageRow.tsx
@@ -14,6 +14,7 @@ import { MessageReactions } from "@/features/messages/ui/MessageReactions";
import { UnreadDivider } from "@/features/messages/ui/UnreadDivider";
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji";
+import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
@@ -44,6 +45,8 @@ type InboxMessageRowProps = {
emoji: string,
remove: boolean,
) => Promise;
+ /** Resolves the mention identities carried by "Copy message". */
+ profiles?: UserProfileLookup;
showUnreadBoundary?: boolean;
videoReviewCommentRootId?: string;
videoReviewContext?: VideoReviewContext;
@@ -61,6 +64,7 @@ export function InboxMessageRow({
onEdit,
onSelectReplyTarget,
onToggleReaction,
+ profiles,
showUnreadBoundary = false,
videoReviewCommentRootId,
videoReviewContext,
@@ -170,6 +174,7 @@ export function InboxMessageRow({
onReply={
canReply ? () => onSelectReplyTarget(message) : undefined
}
+ profiles={profiles}
reactionErrorMessage={reactionErrorMessage}
reactions={reactions}
/>
diff --git a/desktop/src/features/messages/lib/composerMentionCopy.ts b/desktop/src/features/messages/lib/composerMentionCopy.ts
new file mode 100644
index 00000000000..0e334ae379b
--- /dev/null
+++ b/desktop/src/features/messages/lib/composerMentionCopy.ts
@@ -0,0 +1,52 @@
+import type { EditorView } from "@tiptap/pm/view";
+
+import {
+ buildMentionClipboardHtml,
+ type MentionIdentity,
+} from "./mentionClipboard";
+
+/**
+ * Copy / cut out of the composer.
+ *
+ * A composer mention is plain `@Name ` text decorated from a known-names list;
+ * the pubkey lives outside the document. A default copy therefore moves the
+ * words but not the identity, so moving a draft between channels silently
+ * re-resolves (or drops) who was tagged. Writing the identity sidecar here
+ * keeps the exact pubkey attached to the draft.
+ */
+export function handleComposerMentionCopy({
+ event,
+ identities,
+ isCut,
+ view,
+}: {
+ event: ClipboardEvent;
+ identities: readonly MentionIdentity[];
+ isCut: boolean;
+ view: EditorView;
+}): boolean {
+ const clipboardData = event.clipboardData;
+ if (!clipboardData || view.state.selection.empty) return false;
+
+ const slice = view.state.selection.content();
+ // The same serializer ProseMirror would have used, so the plain flavor is
+ // byte-identical to a default copy (Markdown syntax included).
+ const serializeText = view.someProp("clipboardTextSerializer");
+ const text = serializeText
+ ? serializeText(slice, view)
+ : slice.content.textBetween(0, slice.content.size, "\n\n");
+ if (!text) return false;
+
+ const html = buildMentionClipboardHtml({ identities, text });
+ // No known mention in the selection — leave the copy on its default path
+ // rather than replacing ProseMirror's richer HTML flavor for no gain.
+ if (!html) return false;
+
+ event.preventDefault();
+ clipboardData.setData("text/plain", text);
+ clipboardData.setData("text/html", html);
+ if (isCut) {
+ view.dispatch(view.state.tr.deleteSelection().scrollIntoView());
+ }
+ return true;
+}
diff --git a/desktop/src/features/messages/lib/macEmacsTextShortcuts.ts b/desktop/src/features/messages/lib/macEmacsTextShortcuts.ts
new file mode 100644
index 00000000000..9abe6c014e2
--- /dev/null
+++ b/desktop/src/features/messages/lib/macEmacsTextShortcuts.ts
@@ -0,0 +1,107 @@
+import { Extension, type KeyboardShortcutCommand } from "@tiptap/core";
+import type { ResolvedPos } from "@tiptap/pm/model";
+import { Selection } from "@tiptap/pm/state";
+
+import { isMacPlatform } from "@/shared/lib/platform";
+
+/**
+ * Bounds of the hard-break-delimited "line" containing `$from`.
+ *
+ * Chat composers use hard breaks for continuation lines, so a ProseMirror
+ * block spans several visual lines. Emacs-style movement has to respect the
+ * visual line, not the block.
+ */
+export function hardBreakLineBounds($from: ResolvedPos) {
+ const parentStart = $from.start();
+ let start = parentStart;
+ let end = parentStart + $from.parent.content.size;
+
+ $from.parent.forEach((node, offset) => {
+ if (node.type.name !== "hardBreak") return;
+ const breakPosition = parentStart + offset;
+ if (breakPosition < $from.pos) {
+ start = breakPosition + node.nodeSize;
+ } else if (breakPosition >= $from.pos && end > breakPosition) {
+ end = breakPosition;
+ }
+ });
+
+ return { end, start };
+}
+
+/**
+ * macOS text fields traditionally support a small set of Emacs-style Control
+ * shortcuts. Keep movement and kill-line scoped to the current
+ * hard-break-delimited line rather than the whole ProseMirror block.
+ */
+export const MacEmacsTextShortcuts = Extension.create({
+ name: "macEmacsTextShortcuts",
+ addKeyboardShortcuts() {
+ const shortcuts: Record = {};
+ if (!isMacPlatform()) {
+ return shortcuts;
+ }
+
+ return {
+ "Ctrl-a": ({ editor: ed }) => {
+ const { $from } = ed.state.selection;
+ if (!$from.parent.inlineContent) return false;
+ return ed.commands.setTextSelection(hardBreakLineBounds($from).start);
+ },
+ "Ctrl-e": ({ editor: ed }) => {
+ const { $from } = ed.state.selection;
+ if (!$from.parent.inlineContent) return false;
+ return ed.commands.setTextSelection(hardBreakLineBounds($from).end);
+ },
+ "Ctrl-b": ({ editor: ed }) => {
+ const { empty, from } = ed.state.selection;
+ if (!empty || from <= 0) return false;
+ return ed.commands.setTextSelection(from - 1);
+ },
+ "Ctrl-f": ({ editor: ed }) => {
+ const { empty, from } = ed.state.selection;
+ if (!empty || from >= ed.state.doc.content.size) return false;
+ return ed.commands.setTextSelection(from + 1);
+ },
+ "Ctrl-k": ({ editor: ed }) => {
+ const { state, view } = ed;
+ const { $from, empty, from, to } = state.selection;
+
+ if (!empty) {
+ return ed.commands.deleteSelection();
+ }
+
+ if ($from.parent.inlineContent) {
+ const lineEnd = hardBreakLineBounds($from).end;
+ if (from < lineEnd) {
+ return ed.commands.deleteRange({ from, to: lineEnd });
+ }
+
+ const nodeAfter = $from.nodeAfter;
+ if (nodeAfter?.type.name === "hardBreak") {
+ return ed.commands.deleteRange({
+ from,
+ to: from + nodeAfter.nodeSize,
+ });
+ }
+ }
+
+ const blockEnd = $from.end();
+ if (from < blockEnd) {
+ return ed.commands.deleteRange({ from, to: blockEnd });
+ }
+
+ const nextSelection = Selection.findFrom(
+ state.doc.resolve(to),
+ 1,
+ true,
+ );
+ if (!nextSelection) return false;
+
+ const transaction = state.tr.delete(to, nextSelection.from);
+ view.dispatch(transaction.scrollIntoView());
+ return true;
+ },
+ };
+ },
+});
diff --git a/desktop/src/features/messages/lib/mentionClipboard.test.mjs b/desktop/src/features/messages/lib/mentionClipboard.test.mjs
new file mode 100644
index 00000000000..4d8f1830aa2
--- /dev/null
+++ b/desktop/src/features/messages/lib/mentionClipboard.test.mjs
@@ -0,0 +1,442 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { truncateInlineChipLabel } from "@/shared/ui/mentionChip";
+
+import {
+ buildMentionClipboardHtml,
+ getBuzzCopyKind,
+ matchChipTextToLabel,
+ parseMentionClipboardRecords,
+ selectBindableMentionIdentities,
+ selectVisibleMentionIdentities,
+} from "./mentionClipboard.ts";
+
+const JOHN = "a".repeat(64);
+const ALEX = "b".repeat(64);
+const ALEX_KIM = "c".repeat(64);
+const FIZZ = "d".repeat(64);
+
+const john = { label: "John Smith", pubkey: JOHN, isAgent: false };
+
+// ── buildMentionClipboardHtml ─────────────────────────────────────────
+
+test("wraps a multi-word mention with its identity", () => {
+ const html = buildMentionClipboardHtml({
+ text: "@John Smith fixed the bug",
+ identities: [john],
+ });
+
+ assert.equal(
+ html,
+ '' +
+ `' +
+ "@John Smith fixed the bug",
+ );
+});
+
+test("returns null when the body has no known mention", () => {
+ assert.equal(
+ buildMentionClipboardHtml({
+ text: "just a plain message",
+ identities: [john],
+ }),
+ null,
+ );
+ assert.equal(
+ buildMentionClipboardHtml({ text: "@John Smith", identities: [] }),
+ null,
+ );
+});
+
+test("marks agent mentions so the re-lit chip keeps its kind", () => {
+ const html = buildMentionClipboardHtml({
+ text: "ping @Fizz",
+ identities: [{ label: "Fizz", pubkey: FIZZ, isAgent: true }],
+ });
+
+ assert.match(html, /data-mention-kind="agent"/);
+});
+
+test("longest display name wins when one prefixes another", () => {
+ const html = buildMentionClipboardHtml({
+ text: "@Alex Kim shipped it",
+ identities: [
+ { label: "Alex", pubkey: ALEX, isAgent: false },
+ { label: "Alex Kim", pubkey: ALEX_KIM, isAgent: false },
+ ],
+ });
+
+ assert.match(html, new RegExp(`data-mention-pubkey="${ALEX_KIM}"`));
+ assert.doesNotMatch(html, new RegExp(`data-mention-pubkey="${ALEX}"`));
+ assert.match(html, />@Alex Kim<\/span>/);
+});
+
+test("keeps the casing the author wrote", () => {
+ const html = buildMentionClipboardHtml({
+ text: "@john smith fixed it",
+ identities: [john],
+ });
+
+ assert.match(html, />@john smith<\/span>/);
+ assert.match(html, /data-mention-label="John Smith"/);
+});
+
+test("does not wrap mentions inside code spans or fences", () => {
+ assert.equal(
+ buildMentionClipboardHtml({
+ text: "`@John Smith`",
+ identities: [john],
+ }),
+ null,
+ );
+ assert.equal(
+ buildMentionClipboardHtml({
+ text: "```\n@John Smith\n```",
+ identities: [john],
+ }),
+ null,
+ );
+});
+
+test("wraps every occurrence and escapes the surrounding text", () => {
+ const html = buildMentionClipboardHtml({
+ text: "@John Smith & @John Smith",
+ identities: [john],
+ });
+
+ assert.equal(html.match(/data-mention=""/g).length, 2);
+ assert.match(html, / & <b> /);
+});
+
+test("newlines become line breaks in the html flavor", () => {
+ const html = buildMentionClipboardHtml({
+ text: "@John Smith\nsecond line",
+ identities: [john],
+ });
+
+ assert.match(html, / second line/);
+});
+
+test("ignores identities without a well-formed pubkey", () => {
+ assert.equal(
+ buildMentionClipboardHtml({
+ text: "@John Smith",
+ identities: [{ label: "John Smith", pubkey: "nope", isAgent: false }],
+ }),
+ null,
+ );
+});
+
+test("rich copies declare their own flavor", () => {
+ const html = buildMentionClipboardHtml({
+ text: "@John Smith",
+ identities: [john],
+ kind: "rich",
+ });
+
+ assert.match(html, /data-buzz-copy="rich"/);
+});
+
+// ── getBuzzCopyKind ───────────────────────────────────────────────────
+
+test("reads the copy marker, and only Buzz's", () => {
+ assert.equal(
+ getBuzzCopyKind('hi'),
+ "markdown",
+ );
+ assert.equal(getBuzzCopyKind('
hi
'), "rich");
+ assert.equal(getBuzzCopyKind("
hi
"), null);
+ assert.equal(getBuzzCopyKind('hi'), null);
+});
+
+// ── parseMentionClipboardRecords ──────────────────────────────────────
+
+test("recovers records from a Buzz copy", () => {
+ const records = parseMentionClipboardRecords(
+ buildMentionClipboardHtml({
+ text: "@John Smith and @Fizz",
+ identities: [john, { label: "Fizz", pubkey: FIZZ, isAgent: true }],
+ }),
+ );
+
+ assert.deepEqual(records, [
+ { label: "John Smith", pubkey: JOHN, isAgent: false },
+ { label: "Fizz", pubkey: FIZZ, isAgent: true },
+ ]);
+});
+
+test("recovers records from single-quoted, reordered attributes", () => {
+ const records = parseMentionClipboardRecords(
+ `@Jo & Ann`,
+ );
+
+ assert.deepEqual(records, [
+ { label: "Jo & Ann", pubkey: JOHN, isAgent: false },
+ ]);
+});
+
+test("rejects malformed pubkeys", () => {
+ for (const pubkey of ["", "zz", `${JOHN}0`, "g".repeat(64)]) {
+ assert.deepEqual(
+ parseMentionClipboardRecords(
+ `@John`,
+ ),
+ [],
+ `expected ${pubkey} to be rejected`,
+ );
+ }
+});
+
+test("normalizes pubkey casing on both sides of the round trip", () => {
+ const upper = "A".repeat(64);
+ assert.deepEqual(
+ parseMentionClipboardRecords(
+ `@John`,
+ ),
+ [{ label: "John", pubkey: JOHN, isAgent: false }],
+ );
+ assert.match(
+ buildMentionClipboardHtml({
+ text: "@John",
+ identities: [{ label: "John", pubkey: upper, isAgent: false }],
+ }),
+ new RegExp(`data-mention-pubkey="${JOHN}"`),
+ );
+});
+
+test("rejects records without a label and oversized labels", () => {
+ assert.deepEqual(
+ parseMentionClipboardRecords(
+ `@John`,
+ ),
+ [],
+ );
+ assert.deepEqual(
+ parseMentionClipboardRecords(
+ `@x`,
+ ),
+ [],
+ );
+});
+
+test("caps how many records one paste can register", () => {
+ const spans = Array.from(
+ { length: 80 },
+ (_unused, index) =>
+ `@User ${index}`,
+ ).join("");
+
+ assert.equal(parseMentionClipboardRecords(spans).length, 50);
+});
+
+test("does not report the same identity twice", () => {
+ const records = parseMentionClipboardRecords(
+ buildMentionClipboardHtml({
+ text: "@John Smith and @John Smith",
+ identities: [john],
+ }),
+ );
+
+ assert.equal(records.length, 1);
+});
+
+test("finds nothing in foreign clipboard html", () => {
+ assert.deepEqual(
+ parseMentionClipboardRecords("
data-mention-pubkey is a string
"),
+ [],
+ );
+});
+
+// ── selectVisibleMentionIdentities ────────────────────────────────────
+
+const fizz = { label: "Fizz", pubkey: FIZZ, isAgent: true };
+
+test("keeps identities the inserted content mentions", () => {
+ assert.deepEqual(
+ selectVisibleMentionIdentities([john, fizz], "@John Smith fixed the bug"),
+ [john],
+ );
+});
+
+test("drops an identity the inserted content never mentions", () => {
+ // The hostile shape: a hidden record on copied HTML would otherwise rebind
+ // a real member's display name for the rest of the composer session.
+ assert.deepEqual(selectVisibleMentionIdentities([john], "look at this"), []);
+ // The bare name is not a mention — only the sigil form binds.
+ assert.deepEqual(
+ selectVisibleMentionIdentities([john], "John Smith fixed the bug"),
+ [],
+ );
+});
+
+test("holds a partial label to the same standard as the extractor", () => {
+ assert.deepEqual(
+ selectVisibleMentionIdentities([john], "@John fixed it"),
+ [],
+ );
+});
+
+test("ignores a mention the extractor would mask as code", () => {
+ assert.deepEqual(selectVisibleMentionIdentities([john], "`@John Smith`"), []);
+ assert.deepEqual(
+ selectVisibleMentionIdentities([john], "```\n@John Smith\n```"),
+ [],
+ );
+});
+
+// ── selectBindableMentionIdentities ───────────────────────────────────
+
+/** A verifier that vouches for every pair — isolates the other two gates. */
+const vouchForAll = async (records) => records;
+
+test("keeps each recovered pair with its agent flag", async () => {
+ assert.deepEqual(
+ await selectBindableMentionIdentities({
+ html: buildMentionClipboardHtml({
+ text: "@John Smith and @Fizz",
+ identities: [john, fizz],
+ }),
+ text: "@John Smith and @Fizz",
+ verifyMentionIdentities: vouchForAll,
+ }),
+ [john, fizz],
+ );
+});
+
+test("keeps nothing for a record the paste does not show", async () => {
+ // A crafted sidecar: an empty span rebinding a name the content never
+ // carries, riding alongside one the user can actually see.
+ assert.deepEqual(
+ await selectBindableMentionIdentities({
+ html:
+ `` +
+ `@Fizz`,
+ text: "@Fizz take a look",
+ verifyMentionIdentities: vouchForAll,
+ }),
+ [fizz],
+ );
+});
+
+test("keeps nothing for a visible pair trusted state will not vouch for", async () => {
+ // The shape a hostile page carries: a plausible name against a key of its
+ // choosing, written where the user *does* see it. Visibility is not the
+ // question here — the verifier declining it is.
+ const impostor = { label: "John Smith", pubkey: ALEX, isAgent: false };
+ const asked = [];
+ assert.deepEqual(
+ await selectBindableMentionIdentities({
+ html: buildMentionClipboardHtml({
+ text: "@John Smith fixed the bug",
+ identities: [impostor],
+ }),
+ text: "@John Smith fixed the bug",
+ verifyMentionIdentities: async (records) => {
+ asked.push(...records);
+ return [];
+ },
+ }),
+ [],
+ );
+ // The visible pair still reached the verifier: it is the trust answer that
+ // dropped it, not an earlier gate quietly doing the work.
+ assert.deepEqual(asked, [impostor]);
+});
+
+test("binds only what it asked about, whatever the verifier returns", async () => {
+ // The verifier is a seam, not an authority: a bug or a future implementation
+ // that answers with a pair nobody copied must not widen the paste.
+ assert.deepEqual(
+ await selectBindableMentionIdentities({
+ html: buildMentionClipboardHtml({
+ text: "@John Smith fixed the bug",
+ identities: [john],
+ }),
+ text: "@John Smith fixed the bug",
+ verifyMentionIdentities: async (records) => [
+ ...records,
+ { label: "John Smith", pubkey: ALEX, isAgent: false },
+ ],
+ }),
+ [john],
+ );
+});
+
+test("does not consult the verifier when nothing is visible", async () => {
+ let consulted = false;
+ assert.deepEqual(
+ await selectBindableMentionIdentities({
+ html:
+ ``,
+ text: "look at this",
+ verifyMentionIdentities: async () => {
+ consulted = true;
+ return [];
+ },
+ }),
+ [],
+ );
+ assert.equal(consulted, false, "a hidden record must cost no lookup");
+});
+
+// ── matchChipTextToLabel ──────────────────────────────────────────────
+
+test("accepts a full chip, with or without the sigil written back", () => {
+ assert.equal(matchChipTextToLabel("John Smith", "John Smith", "@"), "full");
+ assert.equal(matchChipTextToLabel("@John Smith", "John Smith", "@"), "full");
+ assert.equal(matchChipTextToLabel("#general", "general", "#"), "full");
+});
+
+test("accepts the author's casing and pasteboard whitespace", () => {
+ // `buildMentionSpanHtml` preserves the run as written, not the label's case.
+ assert.equal(matchChipTextToLabel("@john smith", "John Smith", "@"), "full");
+ // A pasteboard round trip can pad the markup or swap spaces for U+00A0.
+ assert.equal(matchChipTextToLabel(" John Smith ", "John Smith", "@"), "full");
+ assert.equal(
+ matchChipTextToLabel("John\u00a0Smith", "John Smith", "@"),
+ "full",
+ );
+});
+
+test("rejects the fragment a boundary-crossing selection leaves behind", () => {
+ // The browser's default copy keeps the chip's attributes around whatever
+ // slice of its text the selection covered — from either end.
+ assert.equal(matchChipTextToLabel("John", "John Smith", "@"), "fragment");
+ assert.equal(matchChipTextToLabel("Smith", "John Smith", "@"), "fragment");
+ assert.equal(matchChipTextToLabel("", "John Smith", "@"), "fragment");
+});
+
+test("rejects a nonempty fragment of an empty declared label", () => {
+ assert.equal(matchChipTextToLabel("John", "", "@"), "fragment");
+ assert.equal(matchChipTextToLabel("", "", "@"), "full");
+});
+
+test("accepts the ellipsized text a chip past the length cap renders", () => {
+ // A long channel name renders truncated while `data-channel-label` still
+ // declares it in full, so a *whole* chip's text is not the label. Reading
+ // that as a fragment would strip the identity off every copy of it.
+ const label = `long-${"a".repeat(80)}-channel`;
+ const rendered = truncateInlineChipLabel(label);
+ assert.notEqual(rendered, label, "fixture must exceed the chip cap");
+
+ assert.equal(matchChipTextToLabel(rendered, label, "#"), "truncated");
+ assert.equal(matchChipTextToLabel(`#${rendered}`, label, "#"), "truncated");
+ // A partial selection of that same chip is still a fragment.
+ assert.equal(
+ matchChipTextToLabel(rendered.slice(0, 10), label, "#"),
+ "fragment",
+ );
+});
+
+test("does not treat an uncapped label's ellipsis as a truncation", () => {
+ // "truncated" is only ever the cap's own output — a label that renders
+ // whole must not gain a second accepted form.
+ assert.equal(matchChipTextToLabel("John…", "John Smith", "@"), "fragment");
+});
diff --git a/desktop/src/features/messages/lib/mentionClipboard.ts b/desktop/src/features/messages/lib/mentionClipboard.ts
new file mode 100644
index 00000000000..19309a9cbb6
--- /dev/null
+++ b/desktop/src/features/messages/lib/mentionClipboard.ts
@@ -0,0 +1,416 @@
+import { truncateInlineChipLabel } from "@/shared/ui/mentionChip";
+
+import { getMentionOffsets } from "./hasMention";
+
+/**
+ * Dual-flavor clipboard support for mentions.
+ *
+ * Every Buzz copy writes two flavors:
+ * - `text/plain` — what a human should read anywhere: sigils restored, no
+ * pubkeys. This is what external apps (TextEdit, Slack, …) receive.
+ * - `text/html` — the same content, with each mention wrapped in a span
+ * carrying its pubkey. Pasting back into a Buzz composer harvests those
+ * records, registers `name → pubkey` with the mention machinery, and the
+ * chip re-lights with the exact tagged identity.
+ *
+ * The wrapper marker declares what the *plain* flavor holds so paste knows
+ * which content path to use — see `BuzzCopyKind`.
+ */
+
+/** Marks clipboard HTML that Buzz produced, and what its plain flavor holds. */
+export const BUZZ_COPY_ATTRIBUTE = "data-buzz-copy";
+/** 64-hex pubkey of the mentioned identity. */
+export const MENTION_PUBKEY_ATTRIBUTE = "data-mention-pubkey";
+/** `human` or `agent` — decides which highlight the re-lit chip gets. */
+export const MENTION_KIND_ATTRIBUTE = "data-mention-kind";
+/** Full mention label, so a partially selected chip is detectable. */
+export const MENTION_LABEL_ATTRIBUTE = "data-mention-label";
+/** Full channel-reference label, same partial-selection role as above. */
+export const CHANNEL_LABEL_ATTRIBUTE = "data-channel-label";
+
+/**
+ * Canonical form for comparing two spellings of one mention label.
+ *
+ * Tolerates what a label picks up in transit and nothing more: a pasteboard
+ * round trip swaps spaces for U+00A0, markup gains padding, and mention
+ * resolution is case-insensitive end to end. Every clipboard comparison of
+ * two labels goes through here, so "the same name" means one thing across the
+ * copy-side chip classifier and the paste-side trust check.
+ */
+export function canonicalMentionLabel(value: string): string {
+ return value
+ .replace(/\u00a0/g, " ")
+ .trim()
+ .toLowerCase();
+}
+
+/** How a chip's copied text relates to the full label it declares. */
+export type ChipTextMatch = "full" | "truncated" | "fragment";
+
+/**
+ * Classify a chip's copied text against the label its attributes declare.
+ *
+ * Both clipboard sides ask this one question — the copy handler deciding
+ * whether to write a sigil back, and the paste normalizer deciding whether to
+ * keep one — so they cannot drift apart on what counts as a whole chip.
+ *
+ * - `full` — the text carries the whole label, allowing for what a legitimate
+ * chip picks up in transit: Buzz's copy handlers write the sigil into the
+ * text, `buildMentionSpanHtml` keeps the author's casing over the label's,
+ * and a pasteboard round trip can swap spaces for U+00A0.
+ * - `truncated` — the text is the ellipsized form `truncateInlineChipLabel`
+ * renders for a label past the inline-chip cap. A *fully* selected long chip
+ * carries this rather than the label, so treating it as a fragment would
+ * strip the identity off every copy of a long channel reference and drop the
+ * whole selection back to the browser's dead-text default.
+ * - `fragment` — anything else: the slice a boundary-crossing selection leaves
+ * behind. It must neither regain a sigil nor keep an identity, so that a
+ * paste can never bind a real pubkey to a partial name.
+ *
+ * Deliberately not an equality test against the rendered text: a chip that
+ * grows any text of its own (a badge, a glyph's text fallback) must not
+ * silently reclassify every chip as a fragment. Text a chip adds *beyond* its
+ * label still reads as `fragment` — the safe direction, since that costs a
+ * copy its identity rather than inventing one.
+ */
+export function matchChipTextToLabel(
+ text: string,
+ label: string,
+ sigil: "@" | "#",
+): ChipTextMatch {
+ const body = canonicalMentionLabel(text);
+ const matches = (form: string) => body === form || body === `${sigil}${form}`;
+ if (matches(canonicalMentionLabel(label))) return "full";
+ // Derived from the helper the chips render with, so the tolerated form
+ // cannot drift from what a fully selected capped chip actually carries.
+ const truncated = truncateInlineChipLabel(label);
+ if (truncated !== label && matches(canonicalMentionLabel(truncated))) {
+ return "truncated";
+ }
+ return "fragment";
+}
+
+/**
+ * What the `text/plain` flavor of a Buzz copy contains.
+ *
+ * - `markdown` — Markdown source (copy-message, composer copy/cut). Paste
+ * inserts the plain flavor so TipTap's Markdown parsing behaves exactly as
+ * it does for a plain-text paste.
+ * - `rich` — rendered timeline HTML. Paste keeps the HTML content path.
+ */
+export type BuzzCopyKind = "markdown" | "rich";
+
+/** A `name → pubkey` pair the composer can register on paste. */
+export type MentionIdentity = {
+ label: string;
+ pubkey: string;
+ isAgent: boolean;
+};
+
+/**
+ * Clipboard HTML is untrusted input — a foreign app can put anything on the
+ * pasteboard. Bound the record count and label length, and require a
+ * well-formed pubkey, before any of it can become an outbound `p` tag.
+ */
+const MAX_MENTION_RECORDS = 50;
+const MAX_MENTION_LABEL_LENGTH = 200;
+
+const HTML_ESCAPES: Record = {
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+ "'": "'",
+};
+
+const HTML_UNESCAPES: Record = {
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ """: '"',
+ "'": "'",
+};
+
+/** Escape a value for interpolation into clipboard HTML text or attributes. */
+export function escapeClipboardHtml(value: string): string {
+ return value.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char] ?? char);
+}
+
+function unescapeClipboardHtml(value: string): string {
+ return value.replace(
+ /&(?:amp|lt|gt|quot|#39);/g,
+ (entity) => HTML_UNESCAPES[entity] ?? entity,
+ );
+}
+
+function isMentionPubkey(value: string): boolean {
+ return /^[0-9a-f]{64}$/.test(value);
+}
+
+/** Wrap one mention occurrence so paste can recover its exact identity. */
+export function buildMentionSpanHtml({
+ identity,
+ text,
+}: {
+ identity: MentionIdentity;
+ /** The matched `@Name` run exactly as it appears in the plain flavor. */
+ text: string;
+}): string {
+ return [
+ `${escapeClipboardHtml(text)}`,
+ ].join("");
+}
+
+type MentionMatch = {
+ offset: number;
+ length: number;
+ identity: MentionIdentity;
+};
+
+/**
+ * Locate every mention of a known identity in `text`.
+ *
+ * Longest-match-wins at each offset, mirroring `extractMentionPubkeys` so the
+ * span we write and the pubkey the send path recovers can't disagree when one
+ * display name prefixes another ("Alex" vs "Alex Kim").
+ */
+function findMentionMatches(
+ text: string,
+ identities: readonly MentionIdentity[],
+): MentionMatch[] {
+ const byOffset = new Map();
+
+ for (const identity of identities) {
+ const label = identity.label.trim();
+ // Callers hand over whatever their own lookup holds; the clipboard is the
+ // wire format here, so it always carries a canonical lowercase pubkey.
+ const pubkey = identity.pubkey.trim().toLowerCase();
+ if (!label || !isMentionPubkey(pubkey)) continue;
+ // `@` + label; `getMentionOffsets` returns the offset of the sigil.
+ const length = label.length + 1;
+ for (const offset of getMentionOffsets(text, label)) {
+ const existing = byOffset.get(offset);
+ if (!existing || existing.length < length) {
+ byOffset.set(offset, {
+ offset,
+ identity: { ...identity, label, pubkey },
+ length,
+ });
+ }
+ }
+ }
+
+ const matches = [...byOffset.values()].sort((a, b) => a.offset - b.offset);
+ // A shorter name nested inside a longer one can match at a later offset
+ // ("@Alex Kim" also matches "Kim" if someone is called that). The outer
+ // match already carries an identity, so drop anything it covers.
+ const disjoint: MentionMatch[] = [];
+ let consumedTo = 0;
+ for (const match of matches) {
+ if (match.offset < consumedTo) continue;
+ disjoint.push(match);
+ consumedTo = match.offset + match.length;
+ }
+ return disjoint;
+}
+
+/**
+ * Build the `text/html` identity sidecar for a plain-text (Markdown) body.
+ *
+ * Returns `null` when the body carries no known mention — callers use that to
+ * leave ordinary copies on their default path rather than replacing the
+ * clipboard's rich flavor for no gain.
+ */
+export function buildMentionClipboardHtml({
+ text,
+ identities,
+ kind = "markdown",
+}: {
+ text: string;
+ identities: readonly MentionIdentity[];
+ kind?: BuzzCopyKind;
+}): string | null {
+ const matches = findMentionMatches(text, identities);
+ if (matches.length === 0) return null;
+
+ const parts: string[] = [];
+ const pushText = (value: string) => {
+ if (value) parts.push(escapeClipboardHtml(value).replace(/\n/g, " "));
+ };
+
+ let cursor = 0;
+ for (const match of matches) {
+ pushText(text.slice(cursor, match.offset));
+ parts.push(
+ buildMentionSpanHtml({
+ identity: match.identity,
+ // Match casing as written, not the identity's canonical casing:
+ // mention resolution is case-insensitive end to end.
+ text: text.slice(match.offset, match.offset + match.length),
+ }),
+ );
+ cursor = match.offset + match.length;
+ }
+ pushText(text.slice(cursor));
+
+ return `${parts.join("")}`;
+}
+
+/** The Buzz copy marker on clipboard HTML, or `null` for foreign HTML. */
+export function getBuzzCopyKind(html: string): BuzzCopyKind | null {
+ const match = html.match(
+ new RegExp(
+ `\\b${BUZZ_COPY_ATTRIBUTE}\\s*=\\s*["'](markdown|rich)["']`,
+ "i",
+ ),
+ );
+ return (match?.[1] as BuzzCopyKind | undefined) ?? null;
+}
+
+function readAttribute(tag: string, name: string): string | null {
+ const match = tag.match(
+ new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i"),
+ );
+ const value = match?.[1] ?? match?.[2];
+ return value === undefined ? null : unescapeClipboardHtml(value);
+}
+
+/**
+ * Recover the `label → pubkey` records a Buzz copy embedded in its HTML.
+ *
+ * Reads the label from `data-mention-label` rather than the element's text so
+ * the result never depends on how a pasteboard round-trip reformatted the
+ * markup. Malformed or oversized records are dropped, not repaired.
+ */
+export function parseMentionClipboardRecords(html: string): MentionIdentity[] {
+ const tagPattern = new RegExp(
+ `<[a-zA-Z][^>]*\\b${MENTION_PUBKEY_ATTRIBUTE}\\s*=[^>]*>`,
+ "g",
+ );
+ const records: MentionIdentity[] = [];
+ const seen = new Set();
+
+ for (const [tag] of html.matchAll(tagPattern)) {
+ if (records.length >= MAX_MENTION_RECORDS) break;
+ const pubkey = readAttribute(tag, MENTION_PUBKEY_ATTRIBUTE)
+ ?.trim()
+ .toLowerCase();
+ const label = readAttribute(tag, MENTION_LABEL_ATTRIBUTE)?.trim();
+ if (
+ !pubkey ||
+ !isMentionPubkey(pubkey) ||
+ !label ||
+ label.length > MAX_MENTION_LABEL_LENGTH
+ ) {
+ continue;
+ }
+ const key = `${label.toLowerCase()} ${pubkey}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ records.push({
+ label,
+ pubkey,
+ isAgent: readAttribute(tag, MENTION_KIND_ATTRIBUTE) === "agent",
+ });
+ }
+
+ return records;
+}
+
+/**
+ * Keep only the records the paste actually shows.
+ *
+ * A record binds a display name for the rest of the composer session, well
+ * past the paste that carried it — so an empty or hidden
+ * `` on any copied
+ * page would silently rebind a real member's name, and a later hand-typed
+ * `@Jane Doe` would chip-light convincingly against the attacker's pubkey.
+ * Requiring the label to be mentioned in the inserted content keeps every
+ * binding visible to the user who accepted it.
+ *
+ * `getMentionOffsets` is the same matcher the send-time extractor uses, so a
+ * dropped record is one that could not have tagged anyone from this content
+ * anyway — code spans and fences excluded on the same terms.
+ */
+export function selectVisibleMentionIdentities(
+ records: readonly MentionIdentity[],
+ text: string,
+): MentionIdentity[] {
+ return records.filter(
+ (record) => getMentionOffsets(text, record.label).length > 0,
+ );
+}
+
+/**
+ * Narrow copied records to the pairs trusted Buzz state vouches for.
+ *
+ * Must resolve to a subset of what it was handed; callers enforce that rather
+ * than assume it, so the seam cannot widen into "the verifier decides what
+ * gets bound". See `useVerifyMentionIdentities` for the implementation and
+ * `mentionIdentityTrust` for why the check exists.
+ */
+export type VerifyMentionIdentities = (
+ records: readonly MentionIdentity[],
+) => Promise;
+
+/** Case-insensitive identity of a `label → pubkey` pair. */
+function mentionIdentityKey(identity: MentionIdentity): string {
+ return `${canonicalMentionLabel(identity.label)} ${identity.pubkey.trim().toLowerCase()}`;
+}
+
+/**
+ * Narrow already-visible records to the pairs the verifier vouches for.
+ *
+ * The result is filtered back down to what was asked about, so the verifier
+ * stays a seam rather than an authority: a bug or a future implementation that
+ * answers with a pair nobody copied cannot widen the paste.
+ */
+export async function selectVouchedMentionIdentities(
+ visible: readonly MentionIdentity[],
+ verifyMentionIdentities: VerifyMentionIdentities,
+): Promise {
+ if (visible.length === 0) return [];
+ const vouched = new Set(
+ (await verifyMentionIdentities(visible)).map(mentionIdentityKey),
+ );
+ return visible.filter((record) => vouched.has(mentionIdentityKey(record)));
+}
+
+/**
+ * The identities a paste is allowed to bind.
+ *
+ * Three conditions, all necessary. The clipboard has to *carry* the record;
+ * the content the paste inserts has to *show* its label, so no binding
+ * outlives a paste the user could not see; and trusted Buzz state has to
+ * *vouch* for the pair, because a visible `@John Smith` beside an attacker's
+ * key is visible either way.
+ *
+ * Binding is what makes a pasted multi-word name known to the mention
+ * decorations *and* to the send-time extractor — so the chip re-lights and
+ * the original pubkey survives the round trip. Everything dropped here stays
+ * readable text that tags nobody.
+ *
+ * The two halves are separately exported because the binder needs the first
+ * one *synchronously*, at paste time, to claim each label it is about to
+ * verify — see `useMentionPasteBinding`.
+ */
+export async function selectBindableMentionIdentities({
+ html,
+ text,
+ verifyMentionIdentities,
+}: {
+ /** Clipboard HTML holding the identity records — untrusted. */
+ html: string;
+ /** The text the paste inserts; a record unmentioned there is discarded. */
+ text: string;
+ verifyMentionIdentities: VerifyMentionIdentities;
+}): Promise {
+ return selectVouchedMentionIdentities(
+ selectVisibleMentionIdentities(parseMentionClipboardRecords(html), text),
+ verifyMentionIdentities,
+ );
+}
diff --git a/desktop/src/features/messages/lib/mentionClipboardPaste.ts b/desktop/src/features/messages/lib/mentionClipboardPaste.ts
new file mode 100644
index 00000000000..70a45dd7063
--- /dev/null
+++ b/desktop/src/features/messages/lib/mentionClipboardPaste.ts
@@ -0,0 +1,82 @@
+import type { EditorView } from "@tiptap/pm/view";
+
+import { getBuzzCopyKind } from "./mentionClipboard";
+import type { BindPastedMentionIdentities } from "./mentionPasteBinding";
+import { normalizeMentionClipboardContent } from "./normalizeMentionClipboard";
+
+/**
+ * Insert `text` through ProseMirror's plain-text paste pipeline.
+ *
+ * `view.pasteText` re-enters `handlePaste` with the original event, so the
+ * clipboard data is rebuilt with only the plain flavor — otherwise the HTML
+ * branch would claim the paste again, forever. That re-entry also means this
+ * function's own call carries no identity records, so it cannot double-bind
+ * the identities its caller is about to hand over.
+ */
+function pastePlainText(view: EditorView, text: string): void {
+ const clipboardData = new DataTransfer();
+ clipboardData.setData("text/plain", text);
+ view.pasteText(text, new ClipboardEvent("paste", { clipboardData }));
+}
+
+/**
+ * Paste clipboard HTML that carries Buzz mention markers.
+ *
+ * Content follows the flavor the copy declared:
+ *
+ * - `markdown` — the copy's plain flavor *is* the Markdown source, so insert
+ * it through the text pipeline and TipTap parses `**bold**` exactly as it
+ * does for any other plain paste.
+ * - `rich` (or legacy Buzz HTML with no marker) — keep the HTML path, with
+ * chip wrappers flattened to sigil-bearing text.
+ *
+ * Identity rides along: binding the records is what makes a pasted multi-word
+ * name known to the composer, so its chip re-lights and the send path recovers
+ * the original pubkey. Each branch is judged against the content *it* inserts
+ * — the plain flavor is not evidence for what the HTML branch shows, and vice
+ * versa — and the range it inserted into is handed over, so a verification
+ * that lands later can tell the mention tokens this paste put on screen from
+ * whatever the user typed next. `bindPastedMentionIdentities` owns the rest; a
+ * composer that passes no binder inserts readable text and binds nothing.
+ */
+export function handleMentionClipboardPaste({
+ bindMentionIdentities,
+ clipboardData,
+ preventDefault,
+ view,
+}: {
+ bindMentionIdentities?: BindPastedMentionIdentities;
+ clipboardData: DataTransfer;
+ preventDefault: () => void;
+ view: EditorView;
+}): boolean {
+ const html = clipboardData.getData("text/html");
+ if (!html) return false;
+
+ // Captured before the insertion; `pasteText`/`pasteHTML` dispatch
+ // synchronously, so the caret afterwards closes the range this paste owns.
+ const insertedFrom = view.state.selection.from;
+ const bind = (insertedText: string) => {
+ if (!bindMentionIdentities) return;
+ bindMentionIdentities({
+ html,
+ insertedText,
+ insertedRange: { from: insertedFrom, to: view.state.selection.to },
+ view,
+ });
+ };
+
+ const text = clipboardData.getData("text/plain");
+ if (getBuzzCopyKind(html) === "markdown" && text) {
+ preventDefault();
+ pastePlainText(view, text);
+ bind(text);
+ return true;
+ }
+
+ const content = normalizeMentionClipboardContent(html);
+ preventDefault();
+ view.pasteHTML(content.html);
+ bind(content.text);
+ return true;
+}
diff --git a/desktop/src/features/messages/lib/mentionIdentityTrust.ts b/desktop/src/features/messages/lib/mentionIdentityTrust.ts
new file mode 100644
index 00000000000..63c8a151325
--- /dev/null
+++ b/desktop/src/features/messages/lib/mentionIdentityTrust.ts
@@ -0,0 +1,67 @@
+import {
+ canonicalMentionLabel,
+ type MentionIdentity,
+} from "./mentionClipboard";
+
+/**
+ * Whether trusted Buzz state vouches for a copied `label → pubkey` pair.
+ *
+ * Clipboard HTML is attacker-authored: any page can carry
+ * `
+ * @John Smith`, and that pastes as a plausible, *visible* mention.
+ * Visibility only proves the user saw a name — not that the name belongs to
+ * the key beside it. A marker Buzz writes proves less still, since an attacker
+ * can write the same marker.
+ *
+ * So the pair itself has to be checked against state the community supplied:
+ * the pubkey's own profile aliases, or a directory entry naming it. A pair
+ * nothing vouches for binds nothing — the words paste as readable text and no
+ * `p` tag carries the key. That is the fail-closed direction: the cost is a
+ * mention that stays plain, never an outbound tag naming the wrong person.
+ */
+
+/**
+ * Does `label` name one of `aliases`?
+ *
+ * Compared through `canonicalMentionLabel`, so a legitimate copy is not
+ * refused over the casing, padding, or U+00A0 a pasteboard round trip leaves
+ * on the declared label.
+ */
+export function isTrustedMentionLabel(
+ label: string,
+ aliases: Iterable,
+): boolean {
+ const wanted = canonicalMentionLabel(label);
+ if (!wanted) return false;
+ for (const alias of aliases) {
+ if (canonicalMentionLabel(alias) === wanted) return true;
+ }
+ return false;
+}
+
+/**
+ * Split records by whether locally held trusted state already vouches for
+ * them, so only the remainder costs a relay round trip.
+ *
+ * `resolveLocalAliases` answers for a normalized pubkey with every name local
+ * trusted state knows it by. An empty answer is "not known here", never
+ * "refuted" — the caller escalates those to the relay, which is the only
+ * source that can speak for a pubkey no local directory has seen.
+ */
+export function partitionMentionIdentitiesByLocalTrust(
+ records: readonly MentionIdentity[],
+ resolveLocalAliases: (pubkey: string) => readonly string[],
+): { trusted: MentionIdentity[]; unresolved: MentionIdentity[] } {
+ const trusted: MentionIdentity[] = [];
+ const unresolved: MentionIdentity[] = [];
+ for (const record of records) {
+ if (
+ isTrustedMentionLabel(record.label, resolveLocalAliases(record.pubkey))
+ ) {
+ trusted.push(record);
+ } else {
+ unresolved.push(record);
+ }
+ }
+ return { trusted, unresolved };
+}
diff --git a/desktop/src/features/messages/lib/mentionPasteBinding.test.mjs b/desktop/src/features/messages/lib/mentionPasteBinding.test.mjs
new file mode 100644
index 00000000000..ddaaae499b8
--- /dev/null
+++ b/desktop/src/features/messages/lib/mentionPasteBinding.test.mjs
@@ -0,0 +1,445 @@
+import assert from "node:assert/strict";
+import { after, afterEach, before, test } from "node:test";
+
+import { getSchema } from "@tiptap/core";
+import { EditorState } from "@tiptap/pm/state";
+import StarterKit from "@tiptap/starter-kit";
+import { JSDOM } from "jsdom";
+
+import { extractMentionPubkeys } from "./extractMentionPubkeys.ts";
+import { PastedMentionOccurrencesExtension } from "./pastedMentionOccurrences.ts";
+
+/**
+ * The three fences a settled paste has to clear, driven through the hook the
+ * composers actually use.
+ *
+ * Verification is deferred by hand here rather than timed, so each case pins
+ * an ordering rather than a race: a paste whose answer is still outstanding, a
+ * newer intent for the same label, and a mention token the user has since
+ * edited. The mention map and `extractMentionPubkeys` are the real ones
+ * `useMentions` writes to and reads with, so what these assert is what a send
+ * would put in its `p` tags.
+ *
+ * The occurrence fence is the `@Label` token rather than the whole insertion,
+ * and that boundary cuts both ways: rewriting the mention itself must cost the
+ * paste its identity even though the sentence around it is untouched, and
+ * rewriting the sentence must *not*, since the slow-verifying non-member case
+ * is the one the feature exists for.
+ */
+
+const dom = new JSDOM("", {
+ url: "http://localhost",
+});
+
+/** 64-hex, the only shape `parseMentionClipboardRecords` lets through. */
+const KEY_A = "a".repeat(64);
+const KEY_B = "b".repeat(64);
+
+const PASTED = "@John Smith fixed the bug";
+const SECOND_PASTE = " and @John Smith agrees";
+const TOKEN = "@John Smith";
+/** A paste with text either side of its mention, as most copies have. */
+const SENTENCE = `Hello ${TOKEN} fixed the bug`;
+
+before(() => {
+ Object.assign(globalThis, {
+ document: dom.window.document,
+ HTMLElement: dom.window.HTMLElement,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ window: dom.window,
+ });
+});
+
+afterEach(async () => {
+ const { cleanup } = await import("@testing-library/react");
+ cleanup();
+});
+
+after(() => dom.window.close());
+
+const schema = getSchema([
+ StarterKit.configure({ heading: false, trailingNode: false, link: false }),
+]);
+const text = (value) => schema.text(value);
+const document_ = (value) =>
+ schema.nodes.doc.create(null, [
+ schema.nodes.paragraph.create(null, [text(value)]),
+ ]);
+
+/** A stand-in for `EditorView` over a real `EditorState` and real plugin. */
+function viewWith(initialText) {
+ const view = {
+ state: EditorState.create({
+ doc: document_(initialText),
+ schema,
+ plugins:
+ PastedMentionOccurrencesExtension.config.addProseMirrorPlugins.call({}),
+ }),
+ dispatch(tr) {
+ view.state = view.state.apply(tr);
+ },
+ };
+ return view;
+}
+
+/** Clipboard HTML in the shape a Buzz copy writes. */
+function clipboardHtml(label, pubkey, body) {
+ return (
+ '' +
+ `@${label}` +
+ `${body}`
+ );
+}
+
+function deferred() {
+ let resolve;
+ const promise = new Promise((settle) => {
+ resolve = settle;
+ });
+ return { promise, resolve };
+}
+
+/**
+ * Render the binder with the mention map `useMentions` keeps, and a verifier
+ * whose answers the test releases one at a time.
+ */
+async function renderBinder() {
+ const { renderHook } = await import("@testing-library/react");
+ const { useMentionPasteBinding } = await import("./mentionPasteBinding.ts");
+
+ /** Stands in for `mentionMapRef.current`; the writer mirrors the hook's. */
+ const mentionMap = new Map();
+ const answers = [];
+ const { result } = renderHook(() =>
+ useMentionPasteBinding({
+ registerVerifiedMentionPubkey: (displayName, pubkey) => {
+ mentionMap.set(displayName.trim(), pubkey);
+ },
+ verifyMentionIdentities: () => {
+ const next = deferred();
+ answers.push(next);
+ return next.promise;
+ },
+ }),
+ );
+
+ return {
+ /** The pubkeys a send would tag for `body`. */
+ extract: (body) =>
+ extractMentionPubkeys({
+ text: body,
+ selectedMentions: mentionMap,
+ selectedDisplayNames: [],
+ memberCandidates: [],
+ }),
+ mentionMap,
+ /** Answer the nth outstanding verification, oldest first. */
+ vouch: (index, identities) => answers[index].resolve(identities),
+ get binding() {
+ return result.current;
+ },
+ };
+}
+
+/** Paste `body`'s records into `view` over the range production hands over. */
+function paste(binding, view, { label, pubkey, body, from, to }) {
+ binding.bindPastedMentionIdentities({
+ html: clipboardHtml(label, pubkey, body.slice(`@${label}`.length)),
+ insertedText: body,
+ insertedRange: { from, to },
+ view,
+ });
+}
+
+/** Paste a whole paragraph the view was built holding. */
+function pasteWholeParagraph(binding, view, { label, pubkey, body }) {
+ paste(binding, view, {
+ label,
+ pubkey,
+ body,
+ from: 1,
+ to: 1 + body.length,
+ });
+}
+
+/** Replace `[at, at + length)` the way a select-and-retype does. */
+function replaceRange(view, at, length, replacement) {
+ view.dispatch(view.state.tr.replaceWith(at, at + length, text(replacement)));
+}
+
+test("a pasted identity binds only once its verification has settled", async () => {
+ // The send seams await `settlePendingMentionBindings` precisely because this
+ // window exists: sending inside it publishes a readable label with no tag.
+ const harness = await renderBinder();
+ const view = viewWith(PASTED);
+ paste(harness.binding, view, {
+ label: "John Smith",
+ pubkey: KEY_A,
+ body: PASTED,
+ from: 1,
+ to: 1 + PASTED.length,
+ });
+
+ assert.deepEqual(harness.extract(PASTED), [], "nothing binds mid-flight");
+
+ const drained = harness.binding.settlePendingMentionBindings();
+ harness.vouch(0, [{ label: "John Smith", pubkey: KEY_A, isAgent: false }]);
+ await drained;
+
+ assert.deepEqual(harness.extract(PASTED), [KEY_A]);
+});
+
+test("a settled paste does not overwrite a newer paste of the same label", async () => {
+ // Slow A, fast B, same label. Both occurrences stay alive, so ordering — not
+ // visibility — is the only thing that can decide which pubkey owns the name.
+ const harness = await renderBinder();
+ const view = viewWith(PASTED + SECOND_PASTE);
+ paste(harness.binding, view, {
+ label: "John Smith",
+ pubkey: KEY_A,
+ body: PASTED,
+ from: 1,
+ to: 1 + PASTED.length,
+ });
+ paste(harness.binding, view, {
+ label: "John Smith",
+ pubkey: KEY_B,
+ body: SECOND_PASTE,
+ from: 1 + PASTED.length,
+ to: 1 + PASTED.length + SECOND_PASTE.length,
+ });
+
+ harness.vouch(1, [{ label: "John Smith", pubkey: KEY_B, isAgent: false }]);
+ harness.vouch(0, [{ label: "John Smith", pubkey: KEY_A, isAgent: false }]);
+ await harness.binding.settlePendingMentionBindings();
+
+ assert.deepEqual(harness.extract(PASTED), [KEY_B]);
+});
+
+test("an explicit selection outranks a paste still being verified", async () => {
+ // What the picker and every other `registerMentionPubkey` caller do: claim
+ // the label, then write it. A paste that resolves afterwards is stale.
+ const harness = await renderBinder();
+ const view = viewWith(PASTED);
+ paste(harness.binding, view, {
+ label: "John Smith",
+ pubkey: KEY_A,
+ body: PASTED,
+ from: 1,
+ to: 1 + PASTED.length,
+ });
+
+ harness.binding.claimMentionIntent("John Smith");
+ harness.mentionMap.set("John Smith", KEY_B);
+
+ harness.vouch(0, [{ label: "John Smith", pubkey: KEY_A, isAgent: false }]);
+ await harness.binding.settlePendingMentionBindings();
+
+ assert.deepEqual(harness.extract(PASTED), [KEY_B]);
+});
+
+test("a paste whose text is gone binds nothing, label elsewhere or not", async () => {
+ // Delete the paste, then write the same name by hand. "Is this label in the
+ // composer?" says yes; the paste no longer owns any of it.
+ const harness = await renderBinder();
+ const view = viewWith(PASTED);
+ paste(harness.binding, view, {
+ label: "John Smith",
+ pubkey: KEY_A,
+ body: PASTED,
+ from: 1,
+ to: 1 + PASTED.length,
+ });
+
+ const pastedEnd = 1 + PASTED.length;
+ view.dispatch(view.state.tr.insertText(SECOND_PASTE, pastedEnd));
+ view.dispatch(view.state.tr.delete(1, pastedEnd));
+ assert.equal(view.state.doc.textContent, SECOND_PASTE);
+
+ harness.vouch(0, [{ label: "John Smith", pubkey: KEY_A, isAgent: false }]);
+ await harness.binding.settlePendingMentionBindings();
+
+ assert.deepEqual(harness.extract(SECOND_PASTE), []);
+});
+
+test("rewriting the pasted mention itself binds nothing to the typed words", async () => {
+ // Select exactly the mention and type it out again. The edit is strictly
+ // inside the paste, so both of the insertion's endpoints survive and its
+ // text is character-for-character what it was — the whole-insertion fence
+ // saw nothing at all, and handed the clipboard's key to hand-typed words.
+ const harness = await renderBinder();
+ const view = viewWith(SENTENCE);
+ pasteWholeParagraph(harness.binding, view, {
+ label: "John Smith",
+ pubkey: KEY_A,
+ body: SENTENCE,
+ });
+
+ replaceRange(view, 1 + SENTENCE.indexOf(TOKEN), TOKEN.length, TOKEN);
+ assert.equal(view.state.doc.textContent, SENTENCE);
+
+ harness.vouch(0, [{ label: "John Smith", pubkey: KEY_A, isAgent: false }]);
+ await harness.binding.settlePendingMentionBindings();
+
+ assert.deepEqual(harness.extract(SENTENCE), []);
+});
+
+test("editing a word beside the pasted mention keeps its identity", async () => {
+ // The payoff for fencing the token rather than the insertion: a lookup that
+ // crosses the network is exactly the case the user has time to tidy the
+ // sentence during, and tidying it must not silently cost the mention.
+ const harness = await renderBinder();
+ const view = viewWith(SENTENCE);
+ pasteWholeParagraph(harness.binding, view, {
+ label: "John Smith",
+ pubkey: KEY_A,
+ body: SENTENCE,
+ });
+
+ const at = 1 + SENTENCE.indexOf("fixed ");
+ view.dispatch(view.state.tr.delete(at, at + "fixed ".length));
+ const edited = SENTENCE.replace("fixed ", "");
+ assert.equal(view.state.doc.textContent, edited);
+
+ harness.vouch(0, [{ label: "John Smith", pubkey: KEY_A, isAgent: false }]);
+ await harness.binding.settlePendingMentionBindings();
+
+ assert.deepEqual(harness.extract(edited), [KEY_A]);
+});
+
+test("rewriting one occurrence leaves the label its other one", async () => {
+ // Each occurrence is fenced on its own, and the name is still on screen off
+ // this paste — so one of them being retyped is not the label's whole claim.
+ const body = `${TOKEN} and ${TOKEN} again`;
+ const harness = await renderBinder();
+ const view = viewWith(body);
+ pasteWholeParagraph(harness.binding, view, {
+ label: "John Smith",
+ pubkey: KEY_A,
+ body,
+ });
+
+ replaceRange(view, 1, TOKEN.length, TOKEN);
+ assert.equal(view.state.doc.textContent, body);
+
+ harness.vouch(0, [{ label: "John Smith", pubkey: KEY_A, isAgent: false }]);
+ await harness.binding.settlePendingMentionBindings();
+
+ assert.deepEqual(harness.extract(body), [KEY_A]);
+});
+
+test("rewriting part of the pasted mention binds nothing", async () => {
+ // Select the first name inside the token and type it again. Neither of the
+ // token's endpoints is touched, so endpoint mapping alone reports a whole
+ // live mention over characters the user wrote — and the text check agrees
+ // with it, because the document reads exactly as it did.
+ const harness = await renderBinder();
+ const view = viewWith(SENTENCE);
+ pasteWholeParagraph(harness.binding, view, {
+ label: "John Smith",
+ pubkey: KEY_A,
+ body: SENTENCE,
+ });
+
+ replaceRange(view, 1 + SENTENCE.indexOf("John"), "John".length, "John");
+ assert.equal(view.state.doc.textContent, SENTENCE);
+
+ harness.vouch(0, [{ label: "John Smith", pubkey: KEY_A, isAgent: false }]);
+ await harness.binding.settlePendingMentionBindings();
+
+ assert.deepEqual(harness.extract(SENTENCE), []);
+});
+
+test("typing inside the pasted mention binds nothing", async () => {
+ // A pure insertion replaces nothing, so the tracked range survives it by
+ // design. What refuses this is the token's own text no longer reading as a
+ // mention of the label the clipboard named.
+ const harness = await renderBinder();
+ const view = viewWith(SENTENCE);
+ pasteWholeParagraph(harness.binding, view, {
+ label: "John Smith",
+ pubkey: KEY_A,
+ body: SENTENCE,
+ });
+
+ view.dispatch(view.state.tr.insertText("y", 1 + SENTENCE.indexOf("Smith")));
+ assert.equal(
+ view.state.doc.textContent,
+ SENTENCE.replace(TOKEN, "@John ySmith"),
+ );
+
+ harness.vouch(0, [{ label: "John Smith", pubkey: KEY_A, isAgent: false }]);
+ await harness.binding.settlePendingMentionBindings();
+
+ // The map entry is the real damage: nothing shows the label now, but a
+ // binding outlives its paste and would light the next one typed by hand.
+ assert.equal(harness.mentionMap.has("John Smith"), false);
+});
+
+test("typing against the pasted mention's edge binds nothing", async () => {
+ // Text typed at either edge lands outside the tracked range on purpose —
+ // and it is exactly the text that destroys the word boundary a mention
+ // needs, so the range reads whole while the document shows no mention.
+ const harness = await renderBinder();
+ const view = viewWith(SENTENCE);
+ pasteWholeParagraph(harness.binding, view, {
+ label: "John Smith",
+ pubkey: KEY_A,
+ body: SENTENCE,
+ });
+
+ view.dispatch(
+ view.state.tr.insertText("x", 1 + SENTENCE.indexOf(TOKEN) + TOKEN.length),
+ );
+ assert.equal(
+ view.state.doc.textContent,
+ SENTENCE.replace(TOKEN, `${TOKEN}x`),
+ );
+
+ harness.vouch(0, [{ label: "John Smith", pubkey: KEY_A, isAgent: false }]);
+ await harness.binding.settlePendingMentionBindings();
+
+ assert.equal(harness.mentionMap.has("John Smith"), false);
+});
+
+test("clearing the composer's mentions retires an in-flight paste", async () => {
+ const harness = await renderBinder();
+ const view = viewWith(PASTED);
+ paste(harness.binding, view, {
+ label: "John Smith",
+ pubkey: KEY_A,
+ body: PASTED,
+ from: 1,
+ to: 1 + PASTED.length,
+ });
+
+ harness.binding.clearMentionIntents();
+
+ harness.vouch(0, [{ label: "John Smith", pubkey: KEY_A, isAgent: false }]);
+ await harness.binding.settlePendingMentionBindings();
+
+ assert.deepEqual(harness.extract(PASTED), []);
+});
+
+test("a hidden record costs no verification and binds nothing", async () => {
+ const harness = await renderBinder();
+ const view = viewWith("look at this");
+ harness.binding.bindPastedMentionIdentities({
+ html:
+ `look at this',
+ insertedText: "look at this",
+ insertedRange: { from: 1, to: 1 + "look at this".length },
+ view,
+ });
+
+ // Nothing to settle: the sync visibility gate declined it before any lookup.
+ await harness.binding.settlePendingMentionBindings();
+ assert.deepEqual(harness.extract(PASTED), []);
+});
+
+test("draining is a no-op when nothing is pending", async () => {
+ const harness = await renderBinder();
+ await harness.binding.settlePendingMentionBindings();
+});
diff --git a/desktop/src/features/messages/lib/mentionPasteBinding.ts b/desktop/src/features/messages/lib/mentionPasteBinding.ts
new file mode 100644
index 00000000000..0b5d0aba47e
--- /dev/null
+++ b/desktop/src/features/messages/lib/mentionPasteBinding.ts
@@ -0,0 +1,298 @@
+import * as React from "react";
+
+import { trimMapToSize } from "@/shared/lib/trimMapToSize";
+
+import {
+ canonicalMentionLabel,
+ parseMentionClipboardRecords,
+ selectVisibleMentionIdentities,
+ selectVouchedMentionIdentities,
+ type MentionIdentity,
+ type VerifyMentionIdentities,
+} from "./mentionClipboard";
+import { findMentionTokenSpans } from "./mentionTokenSpans";
+import {
+ MAX_TRACKED_OCCURRENCES,
+ readPastedMentionOccurrenceRange,
+ releasePastedMentionOccurrence,
+ trackPastedMentionOccurrence,
+ type PastedMentionOccurrenceView,
+} from "./pastedMentionOccurrences";
+
+/** Writes one `name → pubkey` pair into the composer's mention map. */
+export type RegisterMentionPubkey = (
+ displayName: string,
+ pubkey: string,
+ options?: { isAgent?: boolean },
+) => void;
+
+/** What a composer's paste handler hands over for identity binding. */
+export type BindPastedMentionIdentities = (input: {
+ /** Clipboard HTML holding the identity records — untrusted. */
+ html: string;
+ /** The text this paste inserted, as the mention matchers read it. */
+ insertedText: string;
+ /** Where the paste landed; the mention tokens inside it get fenced. */
+ insertedRange: { from: number; to: number };
+ view: PastedMentionOccurrenceView;
+}) => void;
+
+export type MentionPasteBinding = {
+ bindPastedMentionIdentities: BindPastedMentionIdentities;
+ /**
+ * Record explicit user intent for a label, retiring any pending paste that
+ * claimed it. Every caller is a deliberate act — a picker selection, a
+ * resolved insert, a send-time persona registration.
+ */
+ claimMentionIntent: (label: string) => void;
+ /** Drop every claim, so anything still in flight settles into nothing. */
+ clearMentionIntents: () => void;
+ /** Resolve once no paste verification is still deciding what to bind. */
+ settlePendingMentionBindings: () => Promise;
+};
+
+/**
+ * How long a send waits on an in-flight paste verification.
+ *
+ * Generous, because the wait is bounded by a relay round trip the user cannot
+ * see and the alternative is silently dropping the identity they copied. On
+ * expiry the send proceeds with what the composer truthfully shows: the chip
+ * never lit, so plain text sends as plain text.
+ */
+export const PENDING_MENTION_BINDING_TIMEOUT_MS = 10_000;
+
+/** Same bound as the mention maps this feeds. */
+const MAX_TRACKED_INTENTS = 200;
+
+/**
+ * Track a range per mention token this paste put on screen.
+ *
+ * Keyed by canonical label, because that is what a settlement has in hand: one
+ * record can be shown twice, and either occurrence surviving means the paste
+ * still shows the name. The record cap bounds distinct labels but not their
+ * occurrences, so the plugin's own ceiling bounds this too — dropping the
+ * tokens furthest into the paste costs an identity rather than binding one.
+ */
+function trackPastedMentionTokens(
+ view: PastedMentionOccurrenceView,
+ insertedRange: { from: number; to: number },
+ records: readonly MentionIdentity[],
+): Map {
+ const spans = findMentionTokenSpans(
+ view.state.doc,
+ insertedRange,
+ records.map((record) => record.label),
+ ).slice(0, MAX_TRACKED_OCCURRENCES);
+ const tracked = new Map();
+ for (const span of spans) {
+ const id = trackPastedMentionOccurrence(view, span.from, span.to);
+ if (id === null) continue;
+ const key = canonicalMentionLabel(span.label);
+ const ids = tracked.get(key);
+ if (ids) ids.push(id);
+ else tracked.set(key, [id]);
+ }
+ return tracked;
+}
+
+/**
+ * Whether one of this paste's tokens still reads as a mention of `label`.
+ *
+ * Two questions, and a settlement needs both. The tracked range has to still
+ * exist — an edit that deleted or replaced the token retires it — and the
+ * document at that range has to still *be* a mention, which is what catches
+ * the edits a range survives by design: characters typed inside the token
+ * leave both endpoints intact, and characters typed against its outer edge
+ * land outside the range while destroying the word boundary a mention needs.
+ * Asking `findMentionTokenSpans` for a character either side is what puts that
+ * boundary back in view, since the token read alone always supplies one.
+ */
+function ownsLiveMentionToken(
+ view: PastedMentionOccurrenceView,
+ ids: readonly number[] | undefined,
+ label: string,
+): boolean {
+ if (!ids) return false;
+ return ids.some((id) => {
+ const range = readPastedMentionOccurrenceRange(view, id);
+ if (!range) return false;
+ return findMentionTokenSpans(
+ view.state.doc,
+ { from: range.from - 1, to: range.to + 1 },
+ [label],
+ ).some((span) => span.from === range.from && span.to === range.to);
+ });
+}
+
+/**
+ * Bind the identities a paste is entitled to, once they check out.
+ *
+ * Verification can need a relay round trip, so the answer lands after the
+ * insertion — which makes settlement, not the paste, the moment that has to
+ * establish it is still writing what the user meant. Three fences do that,
+ * one per way an unfenced settlement went wrong:
+ *
+ * - **Occurrence.** The `@Label` token *this* paste inserted must still be
+ * there, in the range it landed in (see `pastedMentionOccurrences`).
+ * Deleting the paste and hand-typing the same name previously bound the
+ * clipboard's pubkey to the typed text — and so did retyping the mention on
+ * its own, once the fence was the whole insertion rather than the token.
+ * - **Generation.** The label's newest claim must still be this paste's. Two
+ * pastes of one label, or a picker selection made mid-flight, previously let
+ * whichever verification finished *last* own the name.
+ * - **Trust.** The pair must be one this community's own state vouches for —
+ * `selectVouchedMentionIdentities`, unchanged by the fences above.
+ *
+ * A binding only matters at send time, and `settlePendingMentionBindings` is
+ * what makes that true: the send seams await it, so a still-deciding paste
+ * cannot publish a readable `@Label` with no `p` tag.
+ */
+export function useMentionPasteBinding({
+ registerVerifiedMentionPubkey,
+ verifyMentionIdentities,
+}: {
+ /** The non-bumping map write: settlement is not fresh user intent. */
+ registerVerifiedMentionPubkey: RegisterMentionPubkey;
+ verifyMentionIdentities: VerifyMentionIdentities;
+}): MentionPasteBinding {
+ const registerRef = React.useRef(registerVerifiedMentionPubkey);
+ registerRef.current = registerVerifiedMentionPubkey;
+ const verifyRef = React.useRef(verifyMentionIdentities);
+ verifyRef.current = verifyMentionIdentities;
+ // Newest claim per label, and the counter it is drawn from. A settlement
+ // compares its own claim against the current one, so ordering decides
+ // ownership rather than arrival.
+ const intentsRef = React.useRef