From 4ae299193e625c131cb64a37bc616cdf619f3b83 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 1 Sep 2026 16:42:17 +1000 Subject: [PATCH 01/12] feat(desktop): preserve mentions across copy and paste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copying a message out of the timeline lost the mention: the rendered chip drops the `@` for display, so the clipboard carried "John Smith" — two ordinary words that no composer could bind back to a pubkey. Pasting into another channel produced dead text, and sending it tagged nobody. Every Buzz copy now writes two clipboard flavors in one transaction: - `text/plain` — readable anywhere, sigils restored, no pubkeys. This is what TextEdit, Slack, and every other external app receive. - `text/html` — the same content with each mention wrapped in a span carrying `data-mention-pubkey` / `-label` / `-kind`. On paste, the composer harvests those records, registers each `name → pubkey` with the existing mention machinery, and inserts the content: the chip re-lights and the send path recovers the identity the author tagged. A marker attribute records what the plain flavor holds, so a Markdown copy pastes through the text pipeline and a rendered copy through the HTML one. Covered surfaces: timeline selection copy, thread-panel selection copy, "Copy message", and composer copy/cut — plus paste in both the channel and forum composers. Notes: - Clipboard HTML is untrusted. Records are capped (50), labels bounded (200 chars), and a pubkey must be 64 hex before it can become a `p` tag. - A partially selected chip drops its identity attributes and gains no sigil, so paste can never register a truncated name against a real key. - Mention matching reuses `getMentionOffsets`, so code spans and fences are excluded and the longest display name wins — the same rules the send-time extractor applies. - The plain flavor inlines chip boxes before reading `innerText`; a chip is a flex container, so the browser's own copy split "@John Smith" onto its own line. `MarkdownMention` and `MacEmacsTextShortcuts` are extracted verbatim from `markdown.tsx` and `useRichTextEditor.ts` to keep both files under the size gate. Tests: 20 unit tests over the flavor builder/parser, and a Playwright spec driving the real copy/cut/paste DOM events — timeline copy and "Copy message" of a multi-word non-member mention, pasted into another channel, sent with the original pubkey in its `p` tag; composer copy/cut round trip; plain flavor asserted to contain no 64-hex string; half-selected chip asserted to carry no identity. Signed-off-by: Matt Toohey --- desktop/playwright.config.ts | 1 + .../src/features/forum/ui/ForumComposer.tsx | 24 +- .../messages/lib/composerMentionCopy.ts | 52 +++ .../messages/lib/macEmacsTextShortcuts.ts | 107 ++++++ .../messages/lib/mentionClipboard.test.mjs | 267 ++++++++++++++ .../features/messages/lib/mentionClipboard.ts | 282 +++++++++++++++ .../messages/lib/mentionClipboardPaste.ts | 70 ++++ .../lib/normalizeMentionClipboard.test.mjs | 21 +- .../messages/lib/normalizeMentionClipboard.ts | 20 +- .../messages/lib/timelineMentionCopy.ts | 150 ++++++++ .../src/features/messages/lib/useMentions.ts | 34 ++ .../lib/useMessageMentionIdentities.ts | 43 +++ .../messages/lib/useRichTextEditor.ts | 134 ++----- .../features/messages/ui/MessageActionBar.tsx | 18 + .../features/messages/ui/MessageComposer.tsx | 2 + .../src/features/messages/ui/MessageRow.tsx | 1 + .../messages/ui/MessageThreadPanel.tsx | 2 + .../features/messages/ui/MessageTimeline.tsx | 6 +- .../messages/ui/useComposerPasteHandler.ts | 30 +- desktop/src/shared/lib/clipboard.ts | 20 +- .../shared/ui/markdown/ChannelDeepLink.tsx | 2 + .../shared/ui/markdown/MarkdownMention.tsx | 14 +- desktop/src/testing/e2eBridge.ts | 46 ++- desktop/tests/e2e/mention-clipboard.spec.ts | 332 ++++++++++++++++++ 24 files changed, 1550 insertions(+), 128 deletions(-) create mode 100644 desktop/src/features/messages/lib/composerMentionCopy.ts create mode 100644 desktop/src/features/messages/lib/macEmacsTextShortcuts.ts create mode 100644 desktop/src/features/messages/lib/mentionClipboard.test.mjs create mode 100644 desktop/src/features/messages/lib/mentionClipboard.ts create mode 100644 desktop/src/features/messages/lib/mentionClipboardPaste.ts create mode 100644 desktop/src/features/messages/lib/timelineMentionCopy.ts create mode 100644 desktop/src/features/messages/lib/useMessageMentionIdentities.ts create mode 100644 desktop/tests/e2e/mention-clipboard.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 1e497360359..994e29a2eff 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..ad5ed52ad13 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), @@ -359,6 +358,8 @@ export function ForumComposer({ // ── Media paste ───────────────────────────────────────────────────── const uploadFileRef = React.useRef(media.uploadFile); uploadFileRef.current = media.uploadFile; + const registerMentionPubkeyRef = React.useRef(mentions.registerMentionPubkey); + registerMentionPubkeyRef.current = mentions.registerMentionPubkey; React.useEffect(() => { if (!richText.editor) return; @@ -379,12 +380,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({ + clipboardData, + preventDefault: () => event.preventDefault(), + registerMentionPubkey: registerMentionPubkeyRef.current, + view: _view, + }); } return false; 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..0db9e79b8ec --- /dev/null +++ b/desktop/src/features/messages/lib/mentionClipboard.test.mjs @@ -0,0 +1,267 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildMentionClipboardHtml, + getBuzzCopyKind, + parseMentionClipboardRecords, + registerMentionClipboardIdentities, +} 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

"), + [], + ); +}); + +// ── registerMentionClipboardIdentities ──────────────────────────────── + +test("registers each recovered pair with its agent flag", () => { + const registered = []; + registerMentionClipboardIdentities( + buildMentionClipboardHtml({ + text: "@John Smith and @Fizz", + identities: [john, { label: "Fizz", pubkey: FIZZ, isAgent: true }], + }), + (displayName, pubkey, options) => + registered.push([displayName, pubkey, options?.isAgent]), + ); + + assert.deepEqual(registered, [ + ["John Smith", JOHN, false], + ["Fizz", FIZZ, true], + ]); +}); diff --git a/desktop/src/features/messages/lib/mentionClipboard.ts b/desktop/src/features/messages/lib/mentionClipboard.ts new file mode 100644 index 00000000000..1fd0d2d7734 --- /dev/null +++ b/desktop/src/features/messages/lib/mentionClipboard.ts @@ -0,0 +1,282 @@ +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"; + +/** + * 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; +} + +/** + * Teach a composer every identity a Buzz copy carried. + * + * Registration 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. + */ +export function registerMentionClipboardIdentities( + html: string, + registerMentionPubkey: ( + displayName: string, + pubkey: string, + options?: { isAgent?: boolean }, + ) => void, +): MentionIdentity[] { + const records = parseMentionClipboardRecords(html); + for (const record of records) { + registerMentionPubkey(record.label, record.pubkey, { + isAgent: record.isAgent, + }); + } + return records; +} diff --git a/desktop/src/features/messages/lib/mentionClipboardPaste.ts b/desktop/src/features/messages/lib/mentionClipboardPaste.ts new file mode 100644 index 00000000000..f3b79ba9d02 --- /dev/null +++ b/desktop/src/features/messages/lib/mentionClipboardPaste.ts @@ -0,0 +1,70 @@ +import type { EditorView } from "@tiptap/pm/view"; + +import { + getBuzzCopyKind, + registerMentionClipboardIdentities, +} from "./mentionClipboard"; +import { normalizeMentionClipboardHtml } from "./normalizeMentionClipboard"; + +export type RegisterMentionPubkey = ( + displayName: string, + pubkey: string, + options?: { isAgent?: boolean }, +) => void; + +/** + * 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. + */ +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. + * + * Identity and content are handled separately. Every recognised record is + * registered first — that alone makes a pasted multi-word name known to the + * composer, so its chip re-lights and the send path recovers the original + * pubkey. Content then 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. + */ +export function handleMentionClipboardPaste({ + clipboardData, + preventDefault, + registerMentionPubkey, + view, +}: { + clipboardData: DataTransfer; + preventDefault: () => void; + registerMentionPubkey?: RegisterMentionPubkey; + view: EditorView; +}): boolean { + const html = clipboardData.getData("text/html"); + if (!html) return false; + + if (registerMentionPubkey) { + registerMentionClipboardIdentities(html, registerMentionPubkey); + } + + const text = clipboardData.getData("text/plain"); + if (getBuzzCopyKind(html) === "markdown" && text) { + preventDefault(); + pastePlainText(view, text); + return true; + } + + preventDefault(); + view.pasteHTML(normalizeMentionClipboardHtml(html)); + return true; +} diff --git a/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs b/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs index cecc24df2e1..be8b505b034 100644 --- a/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs +++ b/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { hasMentionClipboardHtml } from "./normalizeMentionClipboard.ts"; +import { + hasMentionClipboardHtml, + 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. @@ -42,3 +45,19 @@ 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("", "@"), ""); +}); diff --git a/desktop/src/features/messages/lib/normalizeMentionClipboard.ts b/desktop/src/features/messages/lib/normalizeMentionClipboard.ts index a04c70dad8b..8f4b2660b66 100644 --- a/desktop/src/features/messages/lib/normalizeMentionClipboard.ts +++ b/desktop/src/features/messages/lib/normalizeMentionClipboard.ts @@ -6,6 +6,17 @@ export function hasMentionClipboardHtml(html: string): boolean { return html.includes("data-mention") || html.includes("data-channel-link"); } +/** + * Put back the `@` / `#` a rendered chip strips for display. + * + * Exported for unit coverage: the surrounding normalization needs a DOM, this + * decision doesn't. + */ +export function restoreChipSigil(text: string, sigil: "@" | "#"): string { + if (!text || text.startsWith(sigil)) return text; + return `${sigil}${text}`; +} + /** * Normalize clipboard HTML that contains Buzz mention / channel-link * elements. Replaces the styled `` and @@ -26,7 +37,14 @@ export function normalizeMentionClipboardHtml(html: string): string { // This preserves the text content inline while stripping the // font-weight/color styles that would confuse Tiptap's mark detection. const span = doc.createElement("span"); - span.textContent = el.textContent ?? ""; + // The rendered chip strips its sigil for display, so flattening it + // verbatim would paste dead text that no composer can re-light. Restore + // the sigil unless the source already carries it (Buzz's own copy + // handlers write it back before the HTML reaches the clipboard). + span.textContent = restoreChipSigil( + el.textContent ?? "", + el.hasAttribute("data-mention") ? "@" : "#", + ); el.replaceWith(span); } diff --git a/desktop/src/features/messages/lib/timelineMentionCopy.ts b/desktop/src/features/messages/lib/timelineMentionCopy.ts new file mode 100644 index 00000000000..da0404bd267 --- /dev/null +++ b/desktop/src/features/messages/lib/timelineMentionCopy.ts @@ -0,0 +1,150 @@ +import type * as React from "react"; + +import { MESSAGE_MARKDOWN_CLASS } from "@/shared/ui/mentionChip"; + +import { + BUZZ_COPY_ATTRIBUTE, + CHANNEL_LABEL_ATTRIBUTE, + MENTION_KIND_ATTRIBUTE, + MENTION_LABEL_ATTRIBUTE, + MENTION_PUBKEY_ATTRIBUTE, +} from "./mentionClipboard"; + +/** + * Selection copy out of the rendered timeline. + * + * A rendered mention chip drops the `@` from its DOM text, so a plain browser + * copy yields dead text ("John Smith") that no composer can re-light. We build + * both clipboard flavors ourselves from a clone of the selection: sigils + * restored, identity attributes intact. + */ + +/** + * Off-screen host for the clone. It must actually be rendered — `innerText` + * falls back to `textContent` (no block newlines) on an unrendered element, + * which would flatten a multi-message selection onto one line. + */ +const CLONE_HOST_STYLE = + "position:fixed;top:0;left:-10000px;width:48rem;opacity:0;pointer-events:none;"; + +/** + * Restore the sigil each chip strips for display. + * + * A chip whose cloned text differs from its full label was only partially + * selected. Prefixing a sigil there would invent a mention the user didn't + * copy, so the fragment degrades to plain text and its identity attributes are + * dropped — paste must not register a name from a partial label. + */ +function restoreChipSigils(root: HTMLElement): boolean { + let restored = false; + + for (const element of root.querySelectorAll("[data-mention]")) { + const label = element.getAttribute(MENTION_LABEL_ATTRIBUTE); + if (label && element.textContent === label) { + element.textContent = `@${label}`; + restored = true; + continue; + } + element.removeAttribute("data-mention"); + element.removeAttribute(MENTION_PUBKEY_ATTRIBUTE); + element.removeAttribute(MENTION_KIND_ATTRIBUTE); + element.removeAttribute(MENTION_LABEL_ATTRIBUTE); + } + + for (const element of root.querySelectorAll( + "[data-channel-link]", + )) { + const label = element.getAttribute(CHANNEL_LABEL_ATTRIBUTE); + if (label && element.textContent === label) { + element.textContent = `#${label}`; + restored = true; + continue; + } + element.removeAttribute("data-channel-link"); + element.removeAttribute(CHANNEL_LABEL_ATTRIBUTE); + } + + return restored; +} + +/** Chip selector matching the inline-flex rules in `globals/markdown.css`. */ +const INLINE_CHIP_SELECTOR = + ".mention-chip, .inline-code-chip, :not(pre) > code"; + +/** + * Collapse chip boxes back to plain inline boxes. + * + * A chip is a flex container, and a chip inside a profile-popover trigger is + * also a flex *item*, so it lays out as a block-level box. `innerText` breaks a + * line around every one of those — "@John Smith\n fixed the bug". Walking up + * from each chip to its block ancestor and forcing `display: inline` restores + * the sentence. This runs on the detached clone, so nothing on screen moves. + */ +function inlineChipBoxes(root: HTMLElement): void { + for (const chip of root.querySelectorAll(INLINE_CHIP_SELECTOR)) { + for ( + let node: HTMLElement | null = chip; + node && node !== root; + node = node.parentElement + ) { + if (getComputedStyle(node).display === "block") break; + node.style.display = "inline"; + } + } +} + +/** + * Build the `text/plain` + `text/html` flavors for a timeline selection. + * + * Returns `null` when the selection carries no chip — an ordinary text copy + * stays on the browser's own path, which serializes it better than we can. + */ +export function buildTimelineClipboardFlavors( + selection: Selection | null, +): { text: string; html: string } | null { + if (!selection || selection.isCollapsed || selection.rangeCount === 0) { + return null; + } + + const clone = document.createElement("div"); + // Message styling is scoped to the message-markdown wrapper, so the clone + // has to carry it for the cloned nodes to lay out the way they do on screen. + clone.className = MESSAGE_MARKDOWN_CLASS; + clone.setAttribute("aria-hidden", "true"); + clone.setAttribute("style", CLONE_HOST_STYLE); + for (let index = 0; index < selection.rangeCount; index += 1) { + clone.append(selection.getRangeAt(index).cloneContents()); + } + + if (!restoreChipSigils(clone)) return null; + + const html = `${clone.innerHTML}`; + + // `innerText` is layout-aware, so the plain flavor keeps the block structure + // of a multi-message selection — with the sigils back and the chips inlined. + document.body.append(clone); + let text = ""; + try { + inlineChipBoxes(clone); + text = clone.innerText; + } finally { + clone.remove(); + } + + return { html, text: text || selection.toString() }; +} + +/** + * `onCopy` for any surface that renders message Markdown. + * + * Left as a no-op (default copy) for selections without chips, and for + * copies whose clipboard data the browser withheld. + */ +export function handleTimelineMentionCopy(event: React.ClipboardEvent): void { + if (event.defaultPrevented) return; + const flavors = buildTimelineClipboardFlavors(window.getSelection()); + if (!flavors) return; + event.preventDefault(); + event.clipboardData.setData("text/plain", flavors.text); + event.clipboardData.setData("text/html", flavors.html); +} diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index f846b545d6c..1720040509b 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -39,6 +39,7 @@ import { useDefaultAgentSuggestion } from "./useDefaultAgentSuggestion"; import { flushMentionDebounce, isPlainSpace } from "./flushMentionDebounce"; import { useAgentMentionRevalidation } from "./agentMentionRevalidation"; import { extractMentionPubkeys } from "./extractMentionPubkeys"; +import type { MentionIdentity } from "./mentionClipboard"; import { extractMentionPersonasFromMaps, type PersonaMentionTarget, @@ -539,6 +540,38 @@ export function useMentions( }, [], ); + const getMentionIdentities = React.useCallback((): MentionIdentity[] => { + const agentNames = new Set( + selectedAgentMentionNamesRef.current.map((name) => + name.trim().toLowerCase(), + ), + ); + const identities: MentionIdentity[] = []; + const claimed = new Set(); + const add = (label: string, pubkey: string, isAgent: boolean) => { + const trimmed = label.trim(); + const key = trimmed.toLowerCase(); + if (!trimmed || !pubkey || claimed.has(key)) return; + claimed.add(key); + identities.push({ isAgent, label: trimmed, pubkey }); + }; + // Explicitly picked mentions first: they are authoritative when a manually + // typed member name collides with one the user selected from the picker. + for (const [label, pubkey] of mentionMapRef.current) { + add(label, pubkey, agentNames.has(label.trim().toLowerCase())); + } + for (const candidate of mentionCandidates) { + if (!candidate.isMember || !candidate.pubkey || !candidate.displayName) { + continue; + } + add( + candidate.displayName, + candidate.pubkey, + knownAgentPubkeys.has(normalizePubkey(candidate.pubkey)), + ); + } + return identities; + }, [knownAgentPubkeys, mentionCandidates]); const insertResolvedMention = React.useCallback( ({ displayName, @@ -824,6 +857,7 @@ export function useMentions( extractMentionPubkeys: extractMentionPubkeysForCurrentMentions, revalidateMentionPubkeys, getDraftMentionRefs, + getMentionIdentities, getMentionDisplayName, handleMentionKeyDown, hasResolvedMembers: members !== undefined, diff --git a/desktop/src/features/messages/lib/useMessageMentionIdentities.ts b/desktop/src/features/messages/lib/useMessageMentionIdentities.ts new file mode 100644 index 00000000000..c30cdd21869 --- /dev/null +++ b/desktop/src/features/messages/lib/useMessageMentionIdentities.ts @@ -0,0 +1,43 @@ +import * as React from "react"; + +import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; + +import type { MentionIdentity } from "./mentionClipboard"; + +/** + * The `label → pubkey` pairs a delivered message tagged. + * + * Same alias set the renderer chips against (`resolveMentionProps`), so any + * `@name` the body shows resolves to the identity the author actually tagged + * — which is exactly what a copy needs to carry. + */ +export function useMessageMentionIdentities( + tags: string[][] | undefined, + profiles: UserProfileLookup | undefined, +): MentionIdentity[] { + const knownAgentPubkeys = useKnownAgentPubkeys(); + return React.useMemo(() => { + const { mentionNames, mentionPubkeysByName } = resolveMentionProps( + tags, + profiles, + ); + if (!mentionNames || !mentionPubkeysByName) return []; + const identities: MentionIdentity[] = []; + for (const label of mentionNames) { + const pubkey = mentionPubkeysByName[label.toLowerCase()]; + if (!pubkey) continue; + const normalized = normalizePubkey(pubkey); + identities.push({ + label, + pubkey: normalized, + isAgent: + knownAgentPubkeys.has(normalized) || + profiles?.[normalized]?.isAgent === true, + }); + } + return identities; + }, [knownAgentPubkeys, profiles, tags]); +} diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 2f00531820d..d2848feb817 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -5,9 +5,8 @@ import { useEditor, type Editor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Placeholder from "@tiptap/extension-placeholder"; import Link from "@tiptap/extension-link"; -import { Extension, type KeyboardShortcutCommand } from "@tiptap/core"; -import { Selection, TextSelection } from "@tiptap/pm/state"; -import type { ResolvedPos } from "@tiptap/pm/model"; +import { Extension } from "@tiptap/core"; +import { TextSelection } from "@tiptap/pm/state"; import { readTextFromSystemClipboard } from "@/shared/api/tauriMedia"; import { @@ -28,6 +27,12 @@ import { settleAutocompleteMentionInsert, syncMentionHighlightFromProps, } from "./mentionHighlightExtension"; +import { handleComposerMentionCopy } from "./composerMentionCopy"; +import { + hardBreakLineBounds, + MacEmacsTextShortcuts, +} from "./macEmacsTextShortcuts"; +import type { MentionIdentity } from "./mentionClipboard"; import { CUSTOM_EMOJI_NODE_NAME } from "./customEmojiNode"; import { useComposerCustomEmoji } from "./useComposerCustomEmoji"; import { buildPlainTextProjection } from "./plainTextProjection"; @@ -45,24 +50,6 @@ import { createComposerLinkPasteHandler } from "./composerMessageLinkNode"; import type { ComposerMessageLinkChannel } from "./useComposerMessageLinks"; import { useComposerMessageLinks } from "./useComposerMessageLinks"; -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 }; -} - /** * Plain-text edit descriptor returned by autocomplete hooks * (mentions / channel links / emoji). Offsets are in plain-text space — @@ -96,6 +83,12 @@ export type RichTextEditorOptions = { messageLinkChannels?: readonly ComposerMessageLinkChannel[]; /** Known custom-emoji set; used to render `:shortcode:` inline as images. */ customEmoji?: CustomEmoji[]; + /** + * `label → pubkey` pairs the composer currently knows. Copy/cut writes them + * into the clipboard's HTML flavor so a draft moved between channels keeps + * the identity it tagged, not just the words. + */ + getMentionIdentities?: () => readonly MentionIdentity[]; /** Called on plain Enter (submit). Handled inside Tiptap's extension system * so it fires *before* ProseMirror's default splitBlock behaviour. */ onSubmit?: () => void; @@ -157,6 +150,7 @@ export function useRichTextEditor({ channelNames, messageLinkChannels, customEmoji, + getMentionIdentities, onSubmit, onEditLastOwnMessage, isAutocompleteOpen, @@ -179,6 +173,9 @@ export function useRichTextEditor({ const onLinkShortcutRef = React.useRef(onLinkShortcut); onLinkShortcutRef.current = onLinkShortcut; + const getMentionIdentitiesRef = React.useRef(getMentionIdentities); + getMentionIdentitiesRef.current = getMentionIdentities; + const placeholderRef = React.useRef(placeholder); placeholderRef.current = placeholder; @@ -217,84 +214,7 @@ export function useRichTextEditor({ // below with custom options (autolink, openOnClick, etc.). link: false, }), - // 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. - 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; - }, - }; - }, - }), + MacEmacsTextShortcuts, // Shift+Enter inside lists/blockquotes: split the node instead of // inserting a hard break so continuation lines keep their formatting. Extension.create({ @@ -450,6 +370,22 @@ export function useRichTextEditor({ ], editorProps: { handleDOMEvents: { + // Both modalities reach the same DOM event: ⌘C/⌘X and the Edit menu + // (and the context menu) all dispatch `copy` / `cut` here. + copy: (view, event) => + handleComposerMentionCopy({ + event: event as ClipboardEvent, + identities: getMentionIdentitiesRef.current?.() ?? [], + isCut: false, + view, + }), + cut: (view, event) => + handleComposerMentionCopy({ + event: event as ClipboardEvent, + identities: getMentionIdentitiesRef.current?.() ?? [], + isCut: true, + view, + }), paste: (view, event) => parseSnapshotClipboardHtml( (event as ClipboardEvent).clipboardData?.getData("text/html") ?? diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index b6fb3d11abf..2b2b308e820 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -19,7 +19,10 @@ import { toast } from "sonner"; import { buildMessageLink } from "@/features/messages/lib/messageLink"; import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker"; import { useCustomEmoji } from "@/features/custom-emoji/hooks"; +import { buildMentionClipboardHtml } from "@/features/messages/lib/mentionClipboard"; import { getThreadReference } from "@/features/messages/lib/threading"; +import { useMessageMentionIdentities } from "@/features/messages/lib/useMessageMentionIdentities"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { ReportMessageDialog } from "@/features/moderation/ui/ReportMessageDialog"; import { MessageModerationMenuItems } from "@/features/moderation/ui/MessageModerationMenuItems"; import type { @@ -95,11 +98,14 @@ function MoreActionsMenu({ open, isFollowingThread, isUnread, + profiles, }: { /** Channel UUID for the "Copy link" action. When null/undefined, the * Copy link entry is hidden (e.g. inbox preview rows that don't have it). */ channelId?: string | null; message: TimelineMessage; + /** Resolves the mention identities carried by "Copy message". */ + profiles?: UserProfileLookup; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; onFollowThread?: (message: TimelineMessage) => void; @@ -127,6 +133,10 @@ function MoreActionsMenu({ const hasCopyActions = !message.pending && message.kind !== KIND_HUDDLE_STARTED; + // "Copy message" copies the Markdown body verbatim, so its plain flavor is + // already readable anywhere. The HTML sidecar adds only identity, letting a + // paste back into Buzz re-light each chip with the pubkey the author tagged. + const mentionIdentities = useMessageMentionIdentities(message.tags, profiles); // A report needs a real, delivered event to target and a known author to // name in the NIP-56 `p` tag. Pending sends and system huddle rows have @@ -225,6 +235,10 @@ function MoreActionsMenu({ copyTextToClipboard( message.body, "Message copied to clipboard", + buildMentionClipboardHtml({ + identities: mentionIdentities, + text: message.body, + }) ?? undefined, ); }} > @@ -400,6 +414,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({ reactions, isFollowingThread, isUnread, + profiles, }: { /** Channel UUID — required for the "Copy link" action; when omitted the * action is hidden (callers like the home inbox that lack the context). */ @@ -422,6 +437,8 @@ export const MessageActionBar = React.memo(function MessageActionBar({ /** Current read state of the clicked message, from the same predicate the * unread badge uses. Drives the single mark-read/unread toggle label. */ isUnread?: boolean; + /** Resolves the mention identities carried by "Copy message". */ + profiles?: UserProfileLookup; }) { const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false); const [isDropdownOpen, setIsDropdownOpen] = React.useState(false); @@ -631,6 +648,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({ open={isDropdownOpen} isFollowingThread={isFollowingThread} isUnread={isUnread} + profiles={profiles} /> ) : null} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 888370e7a39..e76d39b5a29 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -285,6 +285,7 @@ function MessageComposerImpl({ channelNames: channelLinks.knownChannelNames, messageLinkChannels: channelLinks.channels, customEmoji, + getMentionIdentities: mentions.getMentionIdentities, onSubmit: () => submitMessageRef.current(), onEditLastOwnMessage: () => { if (editTargetRef.current) return false; @@ -791,6 +792,7 @@ function MessageComposerImpl({ ); useComposerPasteHandler({ editor: richText.editor, + registerMentionPubkey: mentions.registerMentionPubkey, scrollToBottom: scrollComposerToBottom, setPendingImeta: voiceNote.setPendingImetaWhenIdle, uploadFile: voiceNote.uploadFileWhenIdle, diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 33fc26d13fe..8b7ba6ab72a 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -595,6 +595,7 @@ export const MessageRow = React.memo( : undefined } onUnfollowThread={onUnfollowThread} + profiles={profiles} reactionErrorMessage={reactionErrorMessage} reactions={reactions} /> diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 1de7b140fdb..bec44224d71 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -14,6 +14,7 @@ import { } from "@/features/messages/lib/messageGrouping"; import type { MessageComposerEditTarget } from "@/features/messages/ui/MessageComposer.types"; import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage"; +import { handleTimelineMentionCopy } from "@/features/messages/lib/timelineMentionCopy"; import type { TimelineMessage } from "@/features/messages/types"; import type { VideoReviewPresentation } from "@/features/messages/lib/videoReviewContext"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; @@ -508,6 +509,7 @@ export function MessageThreadPanel({ data-buzz-conversation-scroll data-testid="message-thread-body" mode={isHuddleTranscript ? "panel" : undefined} + onCopy={handleTimelineMentionCopy} onScroll={onScroll} tabIndex={-1} ref={threadBodyRef} diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 88ec8080aab..f33a8fa981c 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -6,6 +6,7 @@ import { selectTimelineBodySurface, selectTimelineIntroSurface, } from "@/features/messages/lib/timelineSnapshot"; +import { handleTimelineMentionCopy } from "@/features/messages/lib/timelineMentionCopy"; import { preloadTimelineImages } from "@/features/messages/lib/timelineImagePreload"; import type { TimelineMessage } from "@/features/messages/types"; import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; @@ -702,7 +703,10 @@ const MessageTimelineBase = React.forwardRef< return ( -
+
{showUnreadPill ? (
void; setPendingImeta: ( update: (current: BlobDescriptor[]) => BlobDescriptor[], @@ -18,6 +21,8 @@ export function useComposerPasteHandler(options: { }) { const uploadFileRef = React.useRef(options.uploadFile); uploadFileRef.current = options.uploadFile; + const registerMentionPubkeyRef = React.useRef(options.registerMentionPubkey); + registerMentionPubkeyRef.current = options.registerMentionPubkey; React.useEffect(() => { const editor = options.editor; if (!editor) return; @@ -57,13 +62,20 @@ export function useComposerPasteHandler(options: { } if (handleAgentSnapshotPaste(event, options.setPendingImeta)) return true; - const html = event.clipboardData?.getData("text/html"); - if (html && hasMentionClipboardHtml(html)) { - event.preventDefault(); - view.pasteHTML(normalizeMentionClipboardHtml(html)); - return true; + const clipboardData = event.clipboardData; + const html = clipboardData?.getData("text/html"); + if (clipboardData && html && hasMentionClipboardHtml(html)) { + if (clipboardData.getData("text/plain").includes("\n")) { + options.scrollToBottom(); + } + return handleMentionClipboardPaste({ + clipboardData, + preventDefault: () => event.preventDefault(), + registerMentionPubkey: registerMentionPubkeyRef.current, + view, + }); } - if ((event.clipboardData?.getData("text/plain") ?? "").includes("\n")) + if ((clipboardData?.getData("text/plain") ?? "").includes("\n")) options.scrollToBottom(); return false; }, diff --git a/desktop/src/shared/lib/clipboard.ts b/desktop/src/shared/lib/clipboard.ts index 93e2bed44e1..09ee69d255c 100644 --- a/desktop/src/shared/lib/clipboard.ts +++ b/desktop/src/shared/lib/clipboard.ts @@ -2,17 +2,27 @@ import { toast } from "sonner"; import { copyTextToSystemClipboard } from "@/shared/api/tauriMedia"; -/** Write plain text through the native clipboard integration. */ -export async function writeTextToClipboard(text: string): Promise { - await copyTextToSystemClipboard(text); +/** + * Write text through the native clipboard integration. + * + * `html` is an optional richer flavor written in the same clipboard + * transaction. External apps read `text`; Buzz reads `html` on paste to + * recover metadata the plain flavor deliberately omits (mention pubkeys). + */ +export async function writeTextToClipboard( + text: string, + html?: string, +): Promise { + await copyTextToSystemClipboard(text, html); } -/** Copy plain text and show standard success/error feedback. */ +/** Copy text and show standard success/error feedback. */ export function copyTextToClipboard( text: string, successMessage = "Copied to clipboard", + html?: string, ) { - void writeTextToClipboard(text) + void writeTextToClipboard(text, html) .then(() => { toast.success(successMessage); }) diff --git a/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx b/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx index ec52adc914f..bfd45eb057a 100644 --- a/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx +++ b/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx @@ -410,6 +410,8 @@ function ChannelReferenceChip({ { + window.__BUZZ_E2E_LAST_CLIPBOARD__ = { html: html ?? null, text }; + if ( + html && + typeof ClipboardItem !== "undefined" && + navigator.clipboard?.write + ) { + try { + await navigator.clipboard.write([ + new ClipboardItem({ + "text/html": new Blob([html], { type: "text/html" }), + "text/plain": new Blob([text], { type: "text/plain" }), + }), + ]); + return; + } catch { + // Fall back to the plain flavor; headless permissions vary by browser. + } + } + await navigator.clipboard.writeText(text); +} + declare global { interface Window { __BUZZ_E2E__?: E2eConfig; + /** Last payload written through the native clipboard command. */ + __BUZZ_E2E_LAST_CLIPBOARD__?: { html: string | null; text: string }; __BUZZ_E2E_COMMANDS__?: string[]; __BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ command: string; @@ -1621,6 +1658,11 @@ const CHARLIE_PUBKEY = "554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea"; const OUTSIDER_PUBKEY = "df8e91b86fda13a9a67896df77232f7bdab2ba9c3e165378e1ba3d24c13a328e"; +// A non-member human whose display name has a space in it. Multi-word names are +// the case a plain-text copy cannot recover — "@John Smith" is indistinguishable +// from "@John" followed by a word — so the clipboard round-trip fixtures use it. +const MULTI_WORD_NON_MEMBER_PUBKEY = + "7c1f2ad0b4e93856a1d0c2f4e6b8093a5d7f1c3e5a79b1d3f5072a4c6e80931b"; const PROFILE_ONLY_AGENT_PUBKEY = "8f83d6b7f3d74f7d933ae3a54dd8c6cc85c7f98e531c16e5a827b953441a8d67"; // A relay-classified bot agent whose declared NIP-OA owner is the mock viewer, @@ -1684,6 +1726,7 @@ const mockDisplayNames = new Map([ [PROFILE_ONLY_AGENT_PUBKEY, "mira"], [OWNED_RELAY_AGENT_PUBKEY, "nadia"], [OUTSIDER_PUBKEY, "outsider"], + [MULTI_WORD_NON_MEMBER_PUBKEY, "John Smith"], [DEFAULT_REAL_IDENTITY.pubkey, DEFAULT_REAL_IDENTITY.username], ]); const mockAgentPubkeys = new Set([ @@ -4149,6 +4192,7 @@ const mockPresence = new Map([ [PROFILE_ONLY_AGENT_PUBKEY, "online"], [OWNED_RELAY_AGENT_PUBKEY, "online"], [OUTSIDER_PUBKEY, "offline"], + [MULTI_WORD_NON_MEMBER_PUBKEY, "offline"], ]); const mockFeedOverrides: RawHomeFeedResponse["feed"] = { mentions: [], @@ -14389,7 +14433,7 @@ export function maybeInstallE2eTauriMocks() { case "copy_image_to_clipboard": return; case "copy_text_to_clipboard": - await navigator.clipboard.writeText((payload as { text: string }).text); + await writeClipboardFlavors(payload as { html?: string; text: string }); return; case "read_clipboard_text": return navigator.clipboard.readText(); diff --git a/desktop/tests/e2e/mention-clipboard.spec.ts b/desktop/tests/e2e/mention-clipboard.spec.ts new file mode 100644 index 00000000000..bd4688c448b --- /dev/null +++ b/desktop/tests/e2e/mention-clipboard.spec.ts @@ -0,0 +1,332 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +/** + * Copying a mention and pasting it back must preserve the identity. + * + * The reported failure is specific to a **multi-word, non-member** display + * name: the rendered chip drops the `@`, so a plain copy yields "John Smith", + * and nothing downstream can tell that from two ordinary words. These tests + * bind the production copy/paste seams — real `copy`/`cut`/`paste` DOM events + * against the timeline and the composer — and assert both clipboard flavors: + * a readable plain flavor with no pubkey in it, and an HTML sidecar that + * carries one. + */ + +/** `mockDisplayNames` maps this to "John Smith"; it joins no mock channel. */ +const JOHN_SMITH_PUBKEY = + "7c1f2ad0b4e93856a1d0c2f4e6b8093a5d7f1c3e5a79b1d3f5072a4c6e80931b"; +const MESSAGE_BODY = "@John Smith fixed the bug"; +/** A pubkey must never reach the flavor an external app pastes. */ +const ANY_64_HEX = /[0-9a-f]{64}/i; + +type ClipboardFlavors = { + defaultPrevented: boolean; + html: string; + text: string; +}; + +test.beforeEach(async ({ page }) => { + await installMockBridge(page); +}); + +async function waitForMockLiveSubscription(page: Page, channelName: string) { + await expect + .poll(() => + page.evaluate( + (currentChannelName) => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: currentChannelName, + }) ?? false, + channelName, + ), + ) + .toBe(true); +} + +// The timeline renders off a `useDeferredValue` snapshot; the list wrapper +// carries `data-render-pending` until that commit lands. +async function waitForTimelineSettled(page: Page) { + await expect(page.locator("[data-render-pending]")).toHaveCount(0); +} + +async function emitMentionMessage(page: Page, channelName: string) { + const event = await page.evaluate( + ({ channel, content, mentionPubkey, pubkey }) => + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: channel, + content, + mentionPubkeys: [mentionPubkey], + pubkey, + }), + { + channel: channelName, + content: MESSAGE_BODY, + mentionPubkey: JOHN_SMITH_PUBKEY, + pubkey: TEST_IDENTITIES.alice.pubkey, + }, + ); + if (!event) throw new Error("Mock message emitter is not installed"); + // The chip is what every copy in this file selects against, so wait for the + // resolved identity rather than for the row. A non-member's profile is not + // in the channel roster, so this waits on a profile round trip — give it + // room beyond the default, since this is setup and not the assertion. + const chip = page + .getByTestId("message-body") + .locator(`[data-mention-pubkey="${JOHN_SMITH_PUBKEY}"]`); + await expect(chip).toHaveText("John Smith", { timeout: 15_000 }); + await waitForTimelineSettled(page); + return event; +} + +/** + * Copy a range of the rendered timeline through the real `copy` event. + * + * `selectChip` narrows the range to the first four characters *inside* the + * mention chip, which is how a user drags across half a name. + */ +async function copyFromTimeline( + page: Page, + { partialChip = false }: { partialChip?: boolean } = {}, +): Promise { + return page.evaluate( + ({ pubkey, selectPartialChip }) => { + // Anchor on the chip, not on the first message body: `general` is seeded + // with unrelated messages that would otherwise win the query. + const chip = document.querySelector( + `[data-testid="message-body"] [data-mention-pubkey="${pubkey}"]`, + ); + if (!chip) throw new Error("Message body rendered no mention chip."); + const body = chip.closest(".message-markdown"); + if (!body) throw new Error("Mention chip is outside a rendered body."); + + const selection = window.getSelection(); + if (!selection) throw new Error("Selection API unavailable."); + selection.removeAllRanges(); + const range = document.createRange(); + if (selectPartialChip) { + const label = chip.firstChild; + if (!label) throw new Error("Mention chip has no text node."); + range.setStart(label, 0); + range.setEnd(label, 4); + } else { + range.selectNodeContents(body); + } + selection.addRange(range); + + const clipboardData = new DataTransfer(); + const event = new ClipboardEvent("copy", { + bubbles: true, + cancelable: true, + clipboardData, + }); + (selectPartialChip ? chip : body).dispatchEvent(event); + return { + defaultPrevented: event.defaultPrevented, + html: clipboardData.getData("text/html"), + text: clipboardData.getData("text/plain"), + }; + }, + { pubkey: JOHN_SMITH_PUBKEY, selectPartialChip: partialChip }, + ); +} + +/** Copy or cut the composer's current selection through the real DOM event. */ +async function copyFromComposer( + page: Page, + type: "copy" | "cut", +): Promise { + return page.getByTestId("message-input").evaluate((element, eventType) => { + const clipboardData = new DataTransfer(); + const event = new ClipboardEvent(eventType, { + bubbles: true, + cancelable: true, + clipboardData, + }); + element.dispatchEvent(event); + return { + defaultPrevented: event.defaultPrevented, + html: clipboardData.getData("text/html"), + text: clipboardData.getData("text/plain"), + }; + }, type); +} + +async function pasteIntoComposer( + page: Page, + flavors: { html: string; text: string }, +) { + const input = page.getByTestId("message-input"); + await input.click(); + await input.evaluate((element, { html, text }) => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", text); + clipboardData.setData("text/html", html); + element.dispatchEvent( + new ClipboardEvent("paste", { + bubbles: true, + cancelable: true, + clipboardData, + }), + ); + }, flavors); +} + +/** + * The `p` tags of the outgoing message whose body is `content`. + * + * A DM is signed client-side and published over the socket rather than through + * `send_channel_message`, so read the event handed to the signer. + */ +async function readSentMentionPubkeys(page: Page, content: string) { + return page.evaluate((expectedContent) => { + for (const entry of window.__BUZZ_E2E_COMMAND_LOG__ ?? []) { + if (entry.command === "send_channel_message") { + const payload = entry.payload as + | { content?: string; mentionPubkeys?: string[] | null } + | undefined; + if (payload?.content !== expectedContent) continue; + return payload.mentionPubkeys ?? []; + } + if (entry.command !== "sign_event") continue; + const unsigned = entry.payload as + | { content?: string; tags?: string[][] } + | undefined; + if (unsigned?.content !== expectedContent) continue; + return (unsigned.tags ?? []) + .filter((tag) => tag[0] === "p" && tag[1]) + .map((tag) => tag[1]); + } + return null; + }, content); +} + +function expectCarriesJohnSmith(flavors: ClipboardFlavors) { + expect(flavors.defaultPrevented).toBe(true); + // Readable anywhere, and safe to hand an external app: the sigil is back and + // no identifier rode along. + expect(flavors.text).toContain("@John Smith"); + expect(flavors.text).not.toMatch(ANY_64_HEX); + // The identity travels in the sidecar flavor instead. + expect(flavors.html).toContain(`data-mention-pubkey="${JOHN_SMITH_PUBKEY}"`); + expect(flavors.html).toContain('data-mention-label="John Smith"'); +} + +async function expectComposerChip(page: Page) { + const input = page.getByTestId("message-input"); + await expect(input).toHaveText(MESSAGE_BODY); + await expect(input.locator(".mention-chip")).toHaveText("John Smith"); +} + +test("timeline selection copy carries a multi-word mention into another channel", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); + await emitMentionMessage(page, "general"); + + const chip = page + .getByTestId("message-row") + .filter({ hasText: "John Smith fixed the bug" }) + .locator("[data-mention]"); + await expect(chip).toHaveAttribute("data-mention-pubkey", JOHN_SMITH_PUBKEY); + await expect(chip).toHaveText("John Smith"); + + const flavors = await copyFromTimeline(page); + expectCarriesJohnSmith(flavors); + expect(flavors.text.trim()).toBe(MESSAGE_BODY); + + // A DM is the destination so the send is not intercepted by the non-member + // invite prompt — the assertion under test is the recovered `p` tag. + await page.getByTestId("channel-bob-tyler").click(); + await expect(page.getByTestId("chat-title")).toHaveText("bob-tyler"); + await pasteIntoComposer(page, flavors); + await expectComposerChip(page); + + await page.getByTestId("send-message").click(); + await expect(page.getByTestId("message-input")).toHaveText(""); + await expect + .poll(() => readSentMentionPubkeys(page, MESSAGE_BODY)) + .toContain(JOHN_SMITH_PUBKEY); +}); + +test("copy message writes the identity sidecar beside readable plain text", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); + const message = await emitMentionMessage(page, "general"); + + const row = page + .getByTestId("message-row") + .filter({ hasText: "John Smith fixed the bug" }); + await row.hover(); + await row.getByTestId(`more-actions-${message.id}`).click({ force: true }); + await page.getByRole("menuitem", { name: "Copy message" }).click(); + + const written = await page.evaluate( + () => window.__BUZZ_E2E_LAST_CLIPBOARD__ ?? null, + ); + expect(written?.text).toBe(MESSAGE_BODY); + expect(written?.text).not.toMatch(ANY_64_HEX); + expect(written?.html).toContain(`data-mention-pubkey="${JOHN_SMITH_PUBKEY}"`); + // "Copy message" copies Markdown source, so paste must take the text path. + expect(written?.html).toContain('data-buzz-copy="markdown"'); + + await page.getByTestId("channel-bob-tyler").click(); + await expect(page.getByTestId("chat-title")).toHaveText("bob-tyler"); + await pasteIntoComposer(page, { + html: written?.html ?? "", + text: written?.text ?? "", + }); + await expectComposerChip(page); +}); + +test("composer copy and cut round-trip the mention they were pasted with", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); + await emitMentionMessage(page, "general"); + + const source = await copyFromTimeline(page); + await pasteIntoComposer(page, source); + await expectComposerChip(page); + + const input = page.getByTestId("message-input"); + await input.press("ControlOrMeta+a"); + expectCarriesJohnSmith(await copyFromComposer(page, "copy")); + await expect(input).toHaveText(MESSAGE_BODY); + + const cut = await copyFromComposer(page, "cut"); + expectCarriesJohnSmith(cut); + await expect(input).toHaveText(""); + + // The cut flavors are a complete round trip on their own. + await pasteIntoComposer(page, cut); + await expectComposerChip(page); +}); + +test("a half-selected chip copies as plain text with no identity attached", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); + await emitMentionMessage(page, "general"); + + // Selecting "John" out of "John Smith" must not invent "@John": registering + // a truncated label would bind the wrong name to a real pubkey. + const flavors = await copyFromTimeline(page, { partialChip: true }); + expect(flavors.defaultPrevented).toBe(false); + expect(flavors.html).toBe(""); + expect(flavors.text).toBe(""); +}); From b73cdd1529cff6d411ff3ffa75aaec0c316a6281 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 2 Sep 2026 11:31:44 +1000 Subject: [PATCH 02/12] fix(desktop): register only the mentions a paste shows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clipboard HTML is untrusted, and paste registered every identity record it carried. An empty `` — invisible in the pasted content, and easy to hide on any copied page — rebound that display name for the rest of the composer session. Registered names take precedence over channel members, so a later hand-written @Jane Doe would chip-light convincingly and carry the attacker's pubkey in its `p` tag. The record cap, label bound, and hex check bounded volume, not this. Each branch now registers only the records whose label is mentioned in the content *it* inserts: the plain flavor for a Markdown copy, the normalized HTML's own text for a rich one. Matching reuses `getMentionOffsets`, the same matcher the send-time extractor applies, so a dropped record is one that could not have tagged anyone from this paste anyway — and every binding that survives is visible to the user who accepted it. `normalizeMentionClipboardHtml` becomes `normalizeMentionClipboardContent` and returns the HTML to insert alongside its rendered text, so the visibility check and the insertion cannot read different markup. Block-tag boundaries become newlines there: a `DOMParser` document is never laid out, so `textContent` would run "…the bug" straight into "@John Smith" and drop a legitimate identity at the head of a paragraph. Tests: five unit tests over the visibility filter and the gated registration, and a Playwright case that pastes a hidden record claiming "John Smith", then writes that name in the composer and sends. With the gate removed the composer lights a chip and the outgoing event carries the impostor's `p` tag — both assertions fail; with it, the full clipboard spec passes. Signed-off-by: Matt Toohey --- .../messages/lib/mentionClipboard.test.mjs | 67 +++++++++++++-- .../features/messages/lib/mentionClipboard.ts | 46 +++++++++-- .../messages/lib/mentionClipboardPaste.ts | 29 +++++-- .../lib/normalizeMentionClipboard.test.mjs | 2 +- .../messages/lib/normalizeMentionClipboard.ts | 82 +++++++++++++++++-- desktop/tests/e2e/mention-clipboard.spec.ts | 41 ++++++++++ 6 files changed, 241 insertions(+), 26 deletions(-) diff --git a/desktop/src/features/messages/lib/mentionClipboard.test.mjs b/desktop/src/features/messages/lib/mentionClipboard.test.mjs index 0db9e79b8ec..92027773c76 100644 --- a/desktop/src/features/messages/lib/mentionClipboard.test.mjs +++ b/desktop/src/features/messages/lib/mentionClipboard.test.mjs @@ -6,6 +6,7 @@ import { getBuzzCopyKind, parseMentionClipboardRecords, registerMentionClipboardIdentities, + selectVisibleMentionIdentities, } from "./mentionClipboard.ts"; const JOHN = "a".repeat(64); @@ -247,21 +248,77 @@ test("finds nothing in foreign clipboard html", () => { ); }); +// ── 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```"), + [], + ); +}); + // ── registerMentionClipboardIdentities ──────────────────────────────── test("registers each recovered pair with its agent flag", () => { const registered = []; - registerMentionClipboardIdentities( - buildMentionClipboardHtml({ + registerMentionClipboardIdentities({ + html: buildMentionClipboardHtml({ text: "@John Smith and @Fizz", - identities: [john, { label: "Fizz", pubkey: FIZZ, isAgent: true }], + identities: [john, fizz], }), - (displayName, pubkey, options) => + registerMentionPubkey: (displayName, pubkey, options) => registered.push([displayName, pubkey, options?.isAgent]), - ); + text: "@John Smith and @Fizz", + }); assert.deepEqual(registered, [ ["John Smith", JOHN, false], ["Fizz", FIZZ, true], ]); }); + +test("registers nothing for a record the paste does not show", () => { + const registered = []; + // A crafted sidecar: an empty span rebinding a name the content never + // carries, riding alongside one the user can actually see. + registerMentionClipboardIdentities({ + html: + `` + + `@Fizz`, + registerMentionPubkey: (displayName, pubkey) => + registered.push([displayName, pubkey]), + text: "@Fizz take a look", + }); + + assert.deepEqual(registered, [["Fizz", FIZZ]]); +}); diff --git a/desktop/src/features/messages/lib/mentionClipboard.ts b/desktop/src/features/messages/lib/mentionClipboard.ts index 1fd0d2d7734..fee9536d341 100644 --- a/desktop/src/features/messages/lib/mentionClipboard.ts +++ b/desktop/src/features/messages/lib/mentionClipboard.ts @@ -258,21 +258,55 @@ export function parseMentionClipboardRecords(html: string): MentionIdentity[] { } /** - * Teach a composer every identity a Buzz copy carried. + * 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, + ); +} + +/** + * Teach a composer every identity a Buzz copy carried *and* showed. * * Registration 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. */ -export function registerMentionClipboardIdentities( - html: string, +export function registerMentionClipboardIdentities({ + html, + registerMentionPubkey, + text, +}: { + /** Clipboard HTML holding the identity records — untrusted. */ + html: string; registerMentionPubkey: ( displayName: string, pubkey: string, options?: { isAgent?: boolean }, - ) => void, -): MentionIdentity[] { - const records = parseMentionClipboardRecords(html); + ) => void; + /** The text the paste inserts; a record unmentioned there is discarded. */ + text: string; +}): MentionIdentity[] { + const records = selectVisibleMentionIdentities( + parseMentionClipboardRecords(html), + text, + ); for (const record of records) { registerMentionPubkey(record.label, record.pubkey, { isAgent: record.isAgent, diff --git a/desktop/src/features/messages/lib/mentionClipboardPaste.ts b/desktop/src/features/messages/lib/mentionClipboardPaste.ts index f3b79ba9d02..b3b9ec98f87 100644 --- a/desktop/src/features/messages/lib/mentionClipboardPaste.ts +++ b/desktop/src/features/messages/lib/mentionClipboardPaste.ts @@ -4,7 +4,7 @@ import { getBuzzCopyKind, registerMentionClipboardIdentities, } from "./mentionClipboard"; -import { normalizeMentionClipboardHtml } from "./normalizeMentionClipboard"; +import { normalizeMentionClipboardContent } from "./normalizeMentionClipboard"; export type RegisterMentionPubkey = ( displayName: string, @@ -28,16 +28,19 @@ function pastePlainText(view: EditorView, text: string): void { /** * Paste clipboard HTML that carries Buzz mention markers. * - * Identity and content are handled separately. Every recognised record is - * registered first — that alone makes a pasted multi-word name known to the - * composer, so its chip re-lights and the send path recovers the original - * pubkey. Content then follows the flavor the copy declared: + * 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: registering 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 registers against the content + * *it* inserts — the plain flavor is not evidence for what the HTML branch + * shows, and vice versa — so a record the user never sees binds nothing. */ export function handleMentionClipboardPaste({ clipboardData, @@ -53,18 +56,26 @@ export function handleMentionClipboardPaste({ const html = clipboardData.getData("text/html"); if (!html) return false; - if (registerMentionPubkey) { - registerMentionClipboardIdentities(html, registerMentionPubkey); - } + const registerVisibleIdentities = (insertedText: string) => { + if (!registerMentionPubkey) return; + registerMentionClipboardIdentities({ + html, + registerMentionPubkey, + text: insertedText, + }); + }; const text = clipboardData.getData("text/plain"); if (getBuzzCopyKind(html) === "markdown" && text) { + registerVisibleIdentities(text); preventDefault(); pastePlainText(view, text); return true; } + const content = normalizeMentionClipboardContent(html); + registerVisibleIdentities(content.text); preventDefault(); - view.pasteHTML(normalizeMentionClipboardHtml(html)); + view.pasteHTML(content.html); return true; } diff --git a/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs b/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs index be8b505b034..3d100bf0b66 100644 --- a/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs +++ b/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs @@ -6,7 +6,7 @@ import { restoreChipSigil, } from "./normalizeMentionClipboard.ts"; -// NOTE: normalizeMentionClipboardHtml uses the browser DOMParser API which +// NOTE: normalizeMentionClipboardContent 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. diff --git a/desktop/src/features/messages/lib/normalizeMentionClipboard.ts b/desktop/src/features/messages/lib/normalizeMentionClipboard.ts index 8f4b2660b66..b811ff13651 100644 --- a/desktop/src/features/messages/lib/normalizeMentionClipboard.ts +++ b/desktop/src/features/messages/lib/normalizeMentionClipboard.ts @@ -17,17 +17,89 @@ export function restoreChipSigil(text: string, sigil: "@" | "#"): string { return `${sigil}${text}`; } +/** + * Tags whose boundaries a reader sees as a line break. + * + * `innerText` derives this from layout, but a `DOMParser` document is never + * rendered, so it falls back to `textContent` and runs "…the bug" straight into + * "@John Smith". The visibility check that reads this text requires a boundary + * before the sigil, so without the breaks a mention opening a paragraph would + * look invisible and lose the identity it was copied with. + */ +const BLOCK_LEVEL_TAGS = new Set([ + "ADDRESS", + "ARTICLE", + "ASIDE", + "BLOCKQUOTE", + "BR", + "DD", + "DIV", + "DL", + "DT", + "FIGCAPTION", + "FIGURE", + "FOOTER", + "H1", + "H2", + "H3", + "H4", + "H5", + "H6", + "HEADER", + "HR", + "LI", + "MAIN", + "NAV", + "OL", + "P", + "PRE", + "SECTION", + "TABLE", + "TD", + "TH", + "TR", + "UL", +]); + +/** The text `node`'s subtree contributes, with block boundaries as newlines. */ +function readRenderedText(node: Node): string { + let text = ""; + for (const child of Array.from(node.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + text += child.nodeValue ?? ""; + continue; + } + if (!(child instanceof Element)) continue; + const inner = readRenderedText(child); + text += BLOCK_LEVEL_TAGS.has(child.tagName) ? `\n${inner}\n` : inner; + } + return text; +} + +/** Clipboard HTML ready to insert, paired with the text it will contribute. */ +export type MentionClipboardContent = { + html: string; + /** + * What the reader will see. Both come from the same parse, so a caller + * deciding what the paste made visible cannot be reading different markup + * from the one being inserted. + */ + text: string; +}; + /** * Normalize clipboard HTML that contains Buzz mention / channel-link * elements. Replaces the styled `` and * `