Files
buzz/desktop/src/features/messages/lib/normalizeMentionClipboard.ts
Matt Toohey b1f6b7ef77 feat(desktop): preserve mentions across copy and paste (#7228)
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
no composer could bind back to a pubkey. Pasting into another channel
produced
dead text, and sending it tagged nobody.

## What changed

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,
forum post/reply selection copy, "Copy message", and composer copy/cut —
plus
paste in both the channel and forum composers.

## Trust boundary

Clipboard HTML is untrusted, and the branch treats it that way:

- Records are capped (50), labels bounded (200 chars), and a pubkey must
be
  64 hex before it can become a `p` tag.
- **Only mentions the paste actually shows are registered.** An empty
`<span data-mention-pubkey=… -label="Jane Doe">` would otherwise rebind
that
display name for the rest of the composer session, so a later
hand-written
@Jane Doe would chip-light convincingly against the attacker's key. Each
  branch registers only the records whose label appears in the text *it*
inserts, matched with `getMentionOffsets` — the same matcher the
send-time
  extractor uses.
- **The visibility gate reads only what ProseMirror will insert.**
`DOMParser` hard-drops `script`, `style`, `title`, `noscript`, `object`,
and
`head` content, so `visible<style>@Jane Doe</style>` beside an empty
chip span
used to smuggle a binding past the gate. Those elements are stripped
before
  either output is derived.
- **A partial chip never gains a sigil.** A selection crossing a chip
boundary
falls through to the browser's default copy, which serializes the full
identity
attributes around a slice of the text — pasting that invented "@Smith"
out of
"John Smith". Paste now leaves a fragment as plain text, tolerating only
what a
whole chip picks up in transit (restored sigil, author casing, U+00A0
swaps,
  and the label cap's own ellipsis).

Both clipboard sides share one `matchChipTextToLabel` helper, in the
module that
owns the label attributes, so copy and paste cannot drift on what counts
as a
whole chip.

## Notes

- Mention matching reuses `getMentionOffsets`, so code spans and fences
are
  excluded and the longest display name wins.
- 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.

## Testing

- ~40 unit tests over the flavor builder/parser, the visibility filter,
the
  ignored-tag sweep, and the chip-match verdicts.
- A Playwright spec (`desktop/tests/e2e/mention-clipboard.spec.ts`)
driving real
  copy/cut/paste DOM events: timeline and "Copy message" of a multi-word
non-member mention pasted into another channel and sent with the
original
pubkey in its `p` tag; the forum copy → forum reply round trip; composer
  copy/cut; plain flavor asserted to contain no 64-hex string; the
boundary-crossing drag; and the hidden-record and `<style>` smuggling
vectors.
- Every regression test is bound to a production seam and fails with its
guard
  removed.
- `just ci` / pre-push lanes green (5927 desktop unit tests, typecheck,
lint,
  file-size gate).

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-04 16:45:13 +10:00

202 lines
7.4 KiB
TypeScript

import {
CHANNEL_LABEL_ATTRIBUTE,
matchChipTextToLabel,
MENTION_LABEL_ATTRIBUTE,
} from "./mentionClipboard";
/**
* Detect whether clipboard HTML contains Buzz mention / channel-link
* elements (marked with `data-mention` or `data-channel-link` attributes).
*/
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}`;
}
/**
* 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",
]);
/**
* Tags whose entire content ProseMirror's `DOMParser` drops on paste — its
* `ignoreTags` list (prosemirror-model 1.25.4).
*
* The visibility gate reads the text returned here, so text under one of these
* must not vouch for an identity record: `view.pasteHTML` never inserts it, and
* a `<style>@Jane Doe</style>` beside an empty record span would otherwise
* register a binding nobody can see. On a prosemirror-model upgrade, mirror
* additions to its list — a tag PM ignores but we count reopens that bypass;
* a tag we strip but PM inserts only over-drops a record, the safe direction.
*/
const PROSEMIRROR_IGNORED_TAGS_SELECTOR =
"head, noscript, object, script, style, title";
/** 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 `<span data-mention>` and
* `<button data-channel-link>` wrappers with unstyled text nodes so
* TipTap's Bold extension doesn't misinterpret their font-weight as bold.
*
* Returns cleaned HTML that preserves surrounding formatting (bold, italic,
* line breaks, etc.) while stripping only the mention/channel-link styling,
* alongside that HTML's rendered text.
*/
export function normalizeMentionClipboardContent(
html: string,
): MentionClipboardContent {
const doc = new DOMParser().parseFromString(html, "text/html");
// Drop what the paste will drop, before either output is derived — the pair
// stays one view of what the composer ends up holding. Sweep the whole
// document, not just `<body>`: the parser hoists a leading `<style>` or
// `<title>` into `<head>`. Stripping ahead of the chip flattening below also
// removes a `data-mention` span nested in `<noscript>`/`<object>` outright,
// rather than flattening it into text the gate would then count as visible.
for (const el of Array.from(
doc.querySelectorAll(PROSEMIRROR_IGNORED_TAGS_SELECTOR),
)) {
el.remove();
}
for (const el of Array.from(
doc.querySelectorAll("[data-mention], [data-channel-link]"),
)) {
// Replace the styled wrapper with a plain <span> containing the text —
// and nothing else, so the identity attributes never reach the composer.
// 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");
const isMention = el.hasAttribute("data-mention");
const sigil = isMention ? "@" : "#";
const label = el.getAttribute(
isMention ? MENTION_LABEL_ATTRIBUTE : CHANNEL_LABEL_ATTRIBUTE,
);
const text = 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) —
// unless the chip is a fragment. Buzz's copy handlers decline a
// selection whose only chip is partially covered, so the browser's
// default copy serializes that chip with its full attributes around the
// covered slice of its text. A sigil there would invent a mention the
// user never copied ("@John" out of "John Smith"); the fragment stays
// plain text, mirroring `restoreChipSigils` on the copy side, and with
// no sigiled label in the inserted content the visibility gate discards
// the identity record too.
//
// Every non-fragment match writes the *declared* label back, trimmed, the
// way `restoreChipSigils` does on the copy side. `matchChipTextToLabel`
// deliberately tolerates what a whole chip picks up in transit — a
// pasteboard's U+00A0 for a space, padding, the author's casing, the
// ellipsis the inline-chip cap renders — but every downstream matcher
// (`getMentionOffsets`, and so the visibility gate, the decorations, and
// the send-time extractor) requires the label's literal characters. Writing
// the text verbatim would let a tolerance leak into the composer as sigiled
// dead text whose identity record is then silently dropped; deriving it
// from the same attribute the records carry keeps the two provably
// consistent, whatever the classifier goes on to tolerate.
const match =
label === null ? "full" : matchChipTextToLabel(text, label, sigil);
span.textContent =
match === "fragment"
? text
: restoreChipSigil(label === null ? text : label.trim(), sigil);
el.replaceWith(span);
}
// Also strip any inline font-weight styles on remaining elements that
// could be misinterpreted as bold by Tiptap (font-weight >= 500).
for (const el of Array.from(doc.querySelectorAll("[style]"))) {
if (el instanceof HTMLElement) {
const fw = el.style.fontWeight;
// Remove font-weight if it's the mention-highlight value (600)
// but not an intentional bold (700/bold).
if (fw === "600") {
el.style.removeProperty("font-weight");
if (!el.getAttribute("style")?.trim()) {
el.removeAttribute("style");
}
}
}
}
return { html: doc.body.innerHTML, text: readRenderedText(doc.body) };
}