mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +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>
43 lines
2.5 KiB
Markdown
43 lines
2.5 KiB
Markdown
# Mention editor contract
|
|
|
|
Autocomplete inserts a full literal label followed by a separator. Full labels
|
|
may contain spaces; internal spaces must not be mistaken for the final boundary.
|
|
The immediate next typed character goes after the separator even if the browser
|
|
remaps the DOM caret to the highlight edge. Explicit ArrowLeft or click cancels
|
|
that settlement so users can intentionally edit a mention. Plain typed tokens
|
|
retain their existing behavior; this does not change recipient resolution.
|
|
|
|
Regression coverage: `mentionHighlightExtension.test.mjs` simulates chip-edge and
|
|
whitespace-run rewrites, internal spaces, and deliberate motion. The ordinary
|
|
member browser cases in `mention-spacing.spec.ts` test immediate typing and
|
|
ArrowLeft without requiring remote discovery or invitation; `mentions.spec.ts`
|
|
also covers clicking chip edges and insertion before existing text.
|
|
|
|
## Pasted mention identities
|
|
|
|
A pasted mention's `label → pubkey` pair is bound only after this community's
|
|
own state vouches for it, and for a non-member that check crosses the network —
|
|
so the answer lands after the text is already on screen. Three fences decide
|
|
whether that late answer may still write the mention map (`mentionPasteBinding`):
|
|
|
|
- **Occurrence.** The label must still be in the text *that paste* inserted.
|
|
`PastedMentionOccurrencesExtension` holds one range per in-flight paste and
|
|
maps it through every transaction; the range dies when its content is deleted
|
|
or replaced, and a settlement with no live range binds nothing. The extension
|
|
is registered in the shared composer extension list, so a new composer gets
|
|
the fence by construction — dropping it there costs pasted identities silently.
|
|
- **Generation.** The label's newest claim must still be this paste's. Every
|
|
explicit act on a label (picker selection, resolved insert, send-time persona)
|
|
claims it, which retires any paste still verifying that name.
|
|
- **Trust.** Unchanged: `useVerifyMentionIdentities` answers from local trusted
|
|
state, then the relay's own profile.
|
|
|
|
Sends await `settlePendingMentionBindings()` before extracting recipients,
|
|
bounded at `PENDING_MENTION_BINDING_TIMEOUT_MS`, so a still-deciding paste
|
|
cannot publish a readable `@Label` with no `p` tag.
|
|
|
|
Regression coverage: `pastedMentionOccurrences.test.mjs` (range ownership
|
|
through the real plugin), `mentionPasteBinding.test.mjs` (ordering, through the
|
|
production hook), and the send-window, namesake, and delete-then-retype cases in
|
|
`mention-clipboard.spec.ts`.
|