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>(new Map()); + const intentCounterRef = React.useRef(0); + const pendingRef = React.useRef>>(new Set()); + + const claimIntent = React.useCallback((label: string): number | null => { + const key = canonicalMentionLabel(label); + if (!key) return null; + const generation = ++intentCounterRef.current; + // Re-insert so the bound below evicts by recency; an evicted claim reads + // as "not current" at settlement, which is the fail-closed direction. + intentsRef.current.delete(key); + intentsRef.current.set(key, generation); + trimMapToSize(intentsRef.current, MAX_TRACKED_INTENTS); + return generation; + }, []); + + const claimMentionIntent = React.useCallback( + (label: string) => { + claimIntent(label); + }, + [claimIntent], + ); + + const clearMentionIntents = React.useCallback(() => { + intentsRef.current.clear(); + }, []); + + const bindPastedMentionIdentities = + React.useCallback( + ({ html, insertedText, insertedRange, view }) => { + const visible = selectVisibleMentionIdentities( + parseMentionClipboardRecords(html), + insertedText, + ); + if (visible.length === 0) return; + // Claimed synchronously, before anything can be awaited: a paste that + // lands later must be able to see that it outranks this one. + const claims = new Map(); + for (const record of visible) { + claims.set( + canonicalMentionLabel(record.label), + claimIntent(record.label), + ); + } + + const tracked = trackPastedMentionTokens(view, insertedRange, visible); + // Nothing tracked is nothing a late answer could be held to, so the + // lookup is not worth spending: the words stay plain and tag nobody. + if (tracked.size === 0) return; + + const settle = async () => { + try { + const vouched = await selectVouchedMentionIdentities( + visible, + verifyRef.current, + ); + for (const record of vouched) { + const key = canonicalMentionLabel(record.label); + if (intentsRef.current.get(key) !== claims.get(key)) continue; + if (!ownsLiveMentionToken(view, tracked.get(key), record.label)) { + continue; + } + registerRef.current(record.label, record.pubkey, { + isAgent: record.isAgent, + }); + } + } catch (error) { + // Nothing is orphaned by giving up here — the pasted words are + // already in the composer and simply stay plain. Retrying an + // identity lookup the user never asked for would be the surprising + // behaviour. + console.warn("Could not verify pasted mention identities", error); + } finally { + for (const ids of tracked.values()) { + for (const id of ids) releasePastedMentionOccurrence(view, id); + } + } + }; + + const settlement = settle().finally(() => { + pendingRef.current.delete(settlement); + }); + pendingRef.current.add(settlement); + }, + [claimIntent], + ); + + const settlePendingMentionBindings = React.useCallback(async () => { + const pending = pendingRef.current; + if (pending.size === 0) return; + let timer: ReturnType | undefined; + const expiry = new Promise<"expired">((resolve) => { + timer = setTimeout( + () => resolve("expired"), + PENDING_MENTION_BINDING_TIMEOUT_MS, + ); + }); + try { + // A settlement can start another (a re-entrant paste cannot, but a + // second composer paste racing this drain can), so loop until empty + // rather than awaiting one snapshot of the set. + while (pending.size > 0) { + const outcome = await Promise.race([ + // The settlements never reject — each one catch-logs internally. + Promise.all([...pending]).then(() => "drained" as const), + expiry, + ]); + if (outcome === "expired") { + console.warn( + `Sending without ${pending.size} unsettled pasted mention identity check(s)`, + ); + return; + } + } + } finally { + if (timer !== undefined) clearTimeout(timer); + } + }, []); + + return React.useMemo( + () => ({ + bindPastedMentionIdentities, + claimMentionIntent, + clearMentionIntents, + settlePendingMentionBindings, + }), + [ + bindPastedMentionIdentities, + claimMentionIntent, + clearMentionIntents, + settlePendingMentionBindings, + ], + ); +} diff --git a/desktop/src/features/messages/lib/mentionTokenSpans.test.mjs b/desktop/src/features/messages/lib/mentionTokenSpans.test.mjs new file mode 100644 index 00000000000..6b49358b3e6 --- /dev/null +++ b/desktop/src/features/messages/lib/mentionTokenSpans.test.mjs @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getSchema } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; + +import { findMentionTokenSpans } from "./mentionTokenSpans.ts"; + +/** + * Mention tokens located in document coordinates. + * + * The fence on a pasted identity is the range its `@Label` occupies, so these + * pin the conversion both ways: a span has to cover the sigil and the whole + * label and nothing else, and it has to be a position the editor agrees with — + * every case reads the span back out of the document and compares text. + */ + +const schema = getSchema([ + StarterKit.configure({ heading: false, trailingNode: false, link: false }), +]); +const text = (value) => schema.text(value); +const paragraph = (...content) => schema.nodes.paragraph.create(null, content); +const document_ = (...content) => schema.nodes.doc.create(null, content); + +const LABEL = "John Smith"; +const TOKEN = `@${LABEL}`; + +/** What the document actually holds at each returned span. */ +function spanTexts(doc, spans) { + return spans.map((span) => doc.textBetween(span.from, span.to, "\n", "\n")); +} + +function wholeDoc(doc) { + return { from: 0, to: doc.content.size }; +} + +test("a span covers the sigil and the whole label", () => { + const doc = document_(paragraph(text(`Hello ${TOKEN} fixed the bug`))); + + const spans = findMentionTokenSpans(doc, wholeDoc(doc), [LABEL]); + + assert.equal(spans.length, 1); + assert.deepEqual(spanTexts(doc, spans), [TOKEN]); + assert.equal(spans[0].label, LABEL); +}); + +test("spans come back in document order across labels", () => { + const doc = document_(paragraph(text(`@Fizz and ${TOKEN} and @Fizz again`))); + + const spans = findMentionTokenSpans(doc, wholeDoc(doc), ["Fizz", LABEL]); + + assert.deepEqual(spanTexts(doc, spans), ["@Fizz", TOKEN, "@Fizz"]); +}); + +test("positions survive a block boundary before the mention", () => { + // The second paragraph's characters sit past its own node position, so a + // span read off a flat offset would land a character or two to the left. + const doc = document_( + paragraph(text("first line")), + paragraph(text(`then ${TOKEN} replied`)), + ); + + const spans = findMentionTokenSpans(doc, wholeDoc(doc), [LABEL]); + + assert.deepEqual(spanTexts(doc, spans), [TOKEN]); +}); + +test("a mention split across a block boundary yields no span", () => { + // The separator makes the run read as a mention of nothing contiguous; + // there is no single range to fence, so there is nothing to bind. + const doc = document_(paragraph(text("@John")), paragraph(text("Smith"))); + + assert.deepEqual(findMentionTokenSpans(doc, wholeDoc(doc), [LABEL]), []); +}); + +test("marks inside the label do not split its span", () => { + // TipTap parses `**@John** Smith` into two text nodes; the token is still + // one contiguous run of characters, and the fence has to hold all of it. + const bold = schema.marks.bold.create(); + const doc = document_( + paragraph(text("@John", [bold]), text(" Smith fixed the bug")), + ); + + const spans = findMentionTokenSpans(doc, wholeDoc(doc), [LABEL]); + + assert.deepEqual(spanTexts(doc, spans), [TOKEN]); +}); + +test("only mentions inside the range are returned", () => { + const doc = document_(paragraph(text(`${TOKEN} and ${TOKEN} again`))); + const second = 1 + `${TOKEN} and `.length; + + const spans = findMentionTokenSpans( + doc, + { from: second, to: doc.content.size }, + [LABEL], + ); + + assert.equal(spans.length, 1); + assert.equal(spans[0].from, second); + assert.deepEqual(spanTexts(doc, spans), [TOKEN]); +}); + +test("a range that cuts the label short yields no span", () => { + // Fail closed: half a token is not the mention the clipboard named. + const doc = document_(paragraph(text(`${TOKEN} fixed the bug`))); + + assert.deepEqual( + findMentionTokenSpans(doc, { from: 1, to: 1 + "@John Sm".length }, [LABEL]), + [], + ); +}); + +test("text with no word boundary after the label yields no span", () => { + // The same rule every other mention layer applies: `@John Smithx` names + // nobody, so nothing there can carry an identity. + const doc = document_(paragraph(text(`${TOKEN}x fixed the bug`))); + + assert.deepEqual(findMentionTokenSpans(doc, wholeDoc(doc), [LABEL]), []); +}); + +test("an empty or inverted range yields no spans", () => { + const doc = document_(paragraph(text(`${TOKEN} fixed the bug`))); + + assert.deepEqual(findMentionTokenSpans(doc, { from: 5, to: 5 }, [LABEL]), []); + assert.deepEqual(findMentionTokenSpans(doc, { from: 9, to: 2 }, [LABEL]), []); +}); + +test("a range past the document is clamped rather than trusted", () => { + // Settlement asks for a character either side of a tracked range, which at + // the document's edge is a position that does not exist. + const doc = document_(paragraph(text(TOKEN))); + + const spans = findMentionTokenSpans( + doc, + { from: -5, to: doc.content.size + 5 }, + [LABEL], + ); + + assert.deepEqual(spanTexts(doc, spans), [TOKEN]); +}); diff --git a/desktop/src/features/messages/lib/mentionTokenSpans.ts b/desktop/src/features/messages/lib/mentionTokenSpans.ts new file mode 100644 index 00000000000..f4de170c18e --- /dev/null +++ b/desktop/src/features/messages/lib/mentionTokenSpans.ts @@ -0,0 +1,122 @@ +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; + +import { getMentionOffsets } from "./hasMention"; + +/** + * Where the `@Label` runs of a document range actually sit. + * + * A pasted identity is fenced by the text it owns, and what it owns is the + * mention token — not the whole sentence the paste happened to carry. Holding + * the token means an edit elsewhere in the paste costs nothing, while any edit + * to the token itself revokes the identity riding on it. + * + * Spans are read out of the *document* rather than out of the clipboard's own + * text, because the document is what the fence has to hold: the Markdown + * branch inserts source that TipTap parses, so `**@John Smith**` on the + * clipboard is `@John Smith` in the doc. `getMentionOffsets` is the matcher + * every other mention layer uses, so a span exists exactly where a send would + * find a mention. + */ + +/** One `@Label` run, in document coordinates. */ +export type MentionTokenSpan = { + label: string; + from: number; + to: number; +}; + +/** Stands in for text a range carries that no single position holds. */ +const NO_POSITION = -1; + +type RangeText = { + text: string; + /** Document position of each character, or `NO_POSITION`. */ + positions: number[]; +}; + +/** + * The text of `[from, to)` beside the document position of each character. + * + * Mirrors `doc.textBetween(from, to, "\n", "\n")` so the matcher reads what + * the rest of the mention machinery reads, and records where every character + * came from so a match converts back into document coordinates. Block + * boundaries and non-text leaves contribute a newline owned by no position: a + * label cannot span one, and a run that did would own no contiguous range. + */ +function readRangeText( + doc: ProseMirrorNode, + from: number, + to: number, +): RangeText { + const chars: string[] = []; + const positions: number[] = []; + let firstBlock = true; + doc.nodesBetween(from, to, (node, pos) => { + if (node.isText) { + const value = node.text ?? ""; + const start = Math.max(from, pos); + const end = Math.min(to, pos + value.length); + for (let at = start; at < end; at += 1) { + chars.push(value[at - pos]); + positions.push(at); + } + return false; + } + if (node.isTextblock) { + if (firstBlock) firstBlock = false; + else { + chars.push("\n"); + positions.push(NO_POSITION); + } + return true; + } + if (node.isLeaf) { + chars.push("\n"); + positions.push(NO_POSITION); + return false; + } + return true; + }); + return { text: chars.join(""), positions }; +} + +/** + * Locate every `@label` token of `labels` inside `[range.from, range.to)`. + * + * Spans come back in document order, so a caller that has to bound how many it + * keeps drops the ones furthest into the range rather than starving a label + * that happens to sort late. + */ +export function findMentionTokenSpans( + doc: ProseMirrorNode, + range: { from: number; to: number }, + labels: readonly string[], +): MentionTokenSpan[] { + const from = Math.max(0, Math.min(range.from, doc.content.size)); + const to = Math.max(from, Math.min(range.to, doc.content.size)); + if (to <= from) return []; + + const { text, positions } = readRangeText(doc, from, to); + const spans: MentionTokenSpan[] = []; + const seen = new Set(); + for (const label of labels) { + // `getMentionOffsets` returns the offset of the sigil, and the match is + // case-insensitive rather than fuzzy, so the run is always `@` + label. + const length = label.length + 1; + for (const offset of getMentionOffsets(text, label)) { + const end = offset + length; + if (end > positions.length) continue; + const start = positions[offset]; + const last = positions[end - 1]; + // A run broken by a block boundary or a non-text node owns no single + // range; skip it rather than inventing one that spans the break. + if (start === NO_POSITION || last === NO_POSITION) continue; + if (last - start !== end - 1 - offset) continue; + const key = `${start} ${last}`; + if (seen.has(key)) continue; + seen.add(key); + spans.push({ label, from: start, to: last + 1 }); + } + } + return spans.sort((a, b) => a.from - b.from); +} diff --git a/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs b/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs index cecc24df2e1..81ccfb4b3df 100644 --- a/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs +++ b/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs @@ -1,11 +1,34 @@ import assert from "node:assert/strict"; -import test from "node:test"; +import { after, before, test } from "node:test"; -import { hasMentionClipboardHtml } from "./normalizeMentionClipboard.ts"; +import { JSDOM } from "jsdom"; +import { truncateInlineChipLabel } from "@/shared/ui/mentionChip"; +import { + parseMentionClipboardRecords, + selectVisibleMentionIdentities, +} from "./mentionClipboard.ts"; +import { + hasMentionClipboardHtml, + normalizeMentionClipboardContent, + restoreChipSigil, +} from "./normalizeMentionClipboard.ts"; -// NOTE: normalizeMentionClipboardHtml uses the browser DOMParser API which -// is not available in Node. Those paths are covered by the e2e paste tests. -// This file tests the pure string-matching detection function. +// `normalizeMentionClipboardContent` needs a DOM; jsdom supplies the same +// globals it reaches for in the webview. +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + DOMParser: dom.window.DOMParser, + Element: dom.window.Element, + HTMLElement: dom.window.HTMLElement, + Node: dom.window.Node, + }); +}); + +after(() => dom.window.close()); // ── hasMentionClipboardHtml ─────────────────────────────────────────── @@ -42,3 +65,203 @@ test("returns false for text that mentions 'data-mention' as content", () => { const html = "

The attribute is called data-mention

"; assert.equal(hasMentionClipboardHtml(html), true); }); + +// ── restoreChipSigil ────────────────────────────────────────────────── + +test("puts back the sigil the rendered chip strips", () => { + assert.equal(restoreChipSigil("John Smith", "@"), "@John Smith"); + assert.equal(restoreChipSigil("general", "#"), "#general"); +}); + +test("leaves an already-sigiled label alone", () => { + assert.equal(restoreChipSigil("@John Smith", "@"), "@John Smith"); + assert.equal(restoreChipSigil("#general", "#"), "#general"); +}); + +test("never invents a lone sigil for empty chip text", () => { + assert.equal(restoreChipSigil("", "@"), ""); +}); + +// ── normalizeMentionClipboardContent ────────────────────────────────── + +/** Nobody's key — what a crafted clipboard sidecar would name instead. */ +const IMPOSTOR_PUBKEY = "1f".repeat(32); +const JOHN_SMITH_PUBKEY = "7c".repeat(32); + +/** The records the paste would actually register, given the inserted text. */ +function visibleIdentities(html, text) { + return selectVisibleMentionIdentities( + parseMentionClipboardRecords(html), + text, + ); +} + +test("text ProseMirror never inserts cannot vouch for an identity record", () => { + // The reported vector: a real member's display name hidden in a " + + `'; + const content = normalizeMentionClipboardContent(html); + + assert.equal(content.text.includes("Jane Doe"), false); + assert.equal(content.html.includes("Jane Doe"), false); + assert.deepEqual(visibleIdentities(html, content.text), []); +}); + +for (const tag of ["script", "style", "title", "noscript", "object"]) { + test(`<${tag}> text reaches neither output`, () => { + const content = normalizeMentionClipboardContent( + `

visible

<${tag}>@Jane Doe `, + ); + assert.equal(content.text.includes("Jane Doe"), false); + assert.equal(content.html.includes("Jane Doe"), false); + // The content the paste does insert survives the sweep. + assert.equal(content.text.includes("visible"), true); + }); +} + +test("a leading visible", + ); + assert.equal(content.text.includes("Jane Doe"), false); + assert.equal(content.html.includes("Jane Doe"), false); + assert.equal(content.text.includes("visible"), true); +}); + +test("a mention span nested in an ignored tag contributes nothing", () => { + // A DOMParser document has scripting disabled, so