mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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>
202 lines
7.4 KiB
TypeScript
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) };
|
|
}
|