mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -9,10 +9,8 @@ import { useComposerFocusOwnership } from "@/features/messages/lib/useComposerFo
|
||||
import { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
|
||||
import { isMentionCodeContext } from "@/features/messages/lib/mentionCodeContext";
|
||||
import { useMentions } from "@/features/messages/lib/useMentions";
|
||||
import {
|
||||
hasMentionClipboardHtml,
|
||||
normalizeMentionClipboardHtml,
|
||||
} from "@/features/messages/lib/normalizeMentionClipboard";
|
||||
import { hasMentionClipboardHtml } from "@/features/messages/lib/normalizeMentionClipboard";
|
||||
import { handleMentionClipboardPaste } from "@/features/messages/lib/mentionClipboardPaste";
|
||||
import {
|
||||
type LinkSelectionInfo,
|
||||
useRichTextEditor,
|
||||
@@ -121,6 +119,7 @@ export function ForumComposer({
|
||||
mentionNames: mentions.knownNames,
|
||||
channelNames: channelLinks.knownChannelNames,
|
||||
messageLinkChannels: channelLinks.channels,
|
||||
getMentionIdentities: mentions.getMentionIdentities,
|
||||
onSubmit: () => submitMessageRef.current(),
|
||||
isAutocompleteOpen: isAutocompleteOpenRef,
|
||||
onEditLink: (info) => onEditLinkRef.current?.(info),
|
||||
@@ -242,6 +241,9 @@ export function ForumComposer({
|
||||
channelLinks.clearChannels();
|
||||
setIsEmojiPickerOpen(false);
|
||||
try {
|
||||
// A pasted mention's identity check can still be in flight; extracting
|
||||
// first would publish the label with no `p` tag. Bounded internally.
|
||||
await mentions.settlePendingMentionBindings();
|
||||
const pubkeys = await mentions.revalidateMentionPubkeys(
|
||||
mentions.extractMentionPubkeys(trimmed),
|
||||
);
|
||||
@@ -292,6 +294,7 @@ export function ForumComposer({
|
||||
mentions.cancelMentionAutocomplete,
|
||||
mentions.extractMentionPubkeys,
|
||||
mentions.revalidateMentionPubkeys,
|
||||
mentions.settlePendingMentionBindings,
|
||||
mentions.clearMentions,
|
||||
channelLinks.clearChannels,
|
||||
richText.clearContent,
|
||||
@@ -359,6 +362,10 @@ export function ForumComposer({
|
||||
// ── Media paste ─────────────────────────────────────────────────────
|
||||
const uploadFileRef = React.useRef(media.uploadFile);
|
||||
uploadFileRef.current = media.uploadFile;
|
||||
const bindMentionIdentitiesRef = React.useRef(
|
||||
mentions.bindPastedMentionIdentities,
|
||||
);
|
||||
bindMentionIdentitiesRef.current = mentions.bindPastedMentionIdentities;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!richText.editor) return;
|
||||
@@ -379,12 +386,15 @@ export function ForumComposer({
|
||||
return true;
|
||||
}
|
||||
|
||||
const html = event.clipboardData?.getData("text/html");
|
||||
if (html && hasMentionClipboardHtml(html)) {
|
||||
const cleanHtml = normalizeMentionClipboardHtml(html);
|
||||
event.preventDefault();
|
||||
_view.pasteHTML(cleanHtml);
|
||||
return true;
|
||||
const clipboardData = event.clipboardData;
|
||||
const html = clipboardData?.getData("text/html");
|
||||
if (clipboardData && html && hasMentionClipboardHtml(html)) {
|
||||
return handleMentionClipboardPaste({
|
||||
bindMentionIdentities: bindMentionIdentitiesRef.current,
|
||||
clipboardData,
|
||||
preventDefault: () => event.preventDefault(),
|
||||
view: _view,
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ArrowLeft, MessageSquare } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { handleTimelineMentionCopy } from "@/features/messages/lib/timelineMentionCopy";
|
||||
import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
@@ -238,6 +239,7 @@ export function ForumThreadPanel({
|
||||
<div
|
||||
className="flex-1 overflow-y-auto"
|
||||
data-scroll-restoration-id={`forum-thread:${channelId}`}
|
||||
onCopy={handleTimelineMentionCopy}
|
||||
ref={scrollRef}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MessageSquareText } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { handleTimelineMentionCopy } from "@/features/messages/lib/timelineMentionCopy";
|
||||
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity";
|
||||
import { getMentionTagPubkey } from "@/shared/lib/resolveMentionNames";
|
||||
@@ -207,6 +208,7 @@ export function ForumView({
|
||||
<div
|
||||
className="flex-1 overflow-y-auto"
|
||||
data-scroll-restoration-id={`forum-list:${channel.id}`}
|
||||
onCopy={handleTimelineMentionCopy}
|
||||
ref={postsScrollRef}
|
||||
>
|
||||
{postsQuery.isLoading ? (
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
hasRenderedVideoAttachment,
|
||||
} from "@/features/messages/lib/videoReviewContext";
|
||||
import { getThreadReference } from "@/features/messages/lib/threading";
|
||||
import { handleTimelineMentionCopy } from "@/features/messages/lib/timelineMentionCopy";
|
||||
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
|
||||
import { useAnchoredScroll } from "@/features/messages/ui/useAnchoredScroll";
|
||||
import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding";
|
||||
@@ -694,6 +695,11 @@ function InboxMessageDetailPane({
|
||||
aria-busy={isThreadContextLoading}
|
||||
className="-mt-13 min-h-0 flex-1 overflow-y-auto overscroll-contain pb-32 pt-13 [overflow-anchor:none]"
|
||||
data-testid="home-inbox-detail-scroll"
|
||||
// Selection copy across a rendered mention chip: restores the sigil
|
||||
// and the identity sidecar the browser's default copy would drop.
|
||||
// Covers only the messages — the composer is a sibling overlay, so
|
||||
// its own copy handler is untouched.
|
||||
onCopy={handleTimelineMentionCopy}
|
||||
onScroll={onScroll}
|
||||
ref={scrollContainerRef}
|
||||
>
|
||||
@@ -768,6 +774,7 @@ function InboxMessageDetailPane({
|
||||
onEdit={canEditMessage ? handleSelectEditTarget : undefined}
|
||||
onSelectReplyTarget={handleSelectReplyTarget}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
showUnreadBoundary={hasUnreadBoundary}
|
||||
videoReviewCommentRootId={videoReviewPresentation.commentRootIdsByMessageId.get(
|
||||
message.id,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { MessageReactions } from "@/features/messages/ui/MessageReactions";
|
||||
import { UnreadDivider } from "@/features/messages/ui/UnreadDivider";
|
||||
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
|
||||
import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
@@ -44,6 +45,8 @@ type InboxMessageRowProps = {
|
||||
emoji: string,
|
||||
remove: boolean,
|
||||
) => Promise<void>;
|
||||
/** Resolves the mention identities carried by "Copy message". */
|
||||
profiles?: UserProfileLookup;
|
||||
showUnreadBoundary?: boolean;
|
||||
videoReviewCommentRootId?: string;
|
||||
videoReviewContext?: VideoReviewContext;
|
||||
@@ -61,6 +64,7 @@ export function InboxMessageRow({
|
||||
onEdit,
|
||||
onSelectReplyTarget,
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
showUnreadBoundary = false,
|
||||
videoReviewCommentRootId,
|
||||
videoReviewContext,
|
||||
@@ -170,6 +174,7 @@ export function InboxMessageRow({
|
||||
onReply={
|
||||
canReply ? () => onSelectReplyTarget(message) : undefined
|
||||
}
|
||||
profiles={profiles}
|
||||
reactionErrorMessage={reactionErrorMessage}
|
||||
reactions={reactions}
|
||||
/>
|
||||
|
||||
52
desktop/src/features/messages/lib/composerMentionCopy.ts
Normal file
52
desktop/src/features/messages/lib/composerMentionCopy.ts
Normal file
@@ -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;
|
||||
}
|
||||
107
desktop/src/features/messages/lib/macEmacsTextShortcuts.ts
Normal file
107
desktop/src/features/messages/lib/macEmacsTextShortcuts.ts
Normal file
@@ -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<string, KeyboardShortcutCommand> = {};
|
||||
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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
442
desktop/src/features/messages/lib/mentionClipboard.test.mjs
Normal file
442
desktop/src/features/messages/lib/mentionClipboard.test.mjs
Normal file
@@ -0,0 +1,442 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { truncateInlineChipLabel } from "@/shared/ui/mentionChip";
|
||||
|
||||
import {
|
||||
buildMentionClipboardHtml,
|
||||
getBuzzCopyKind,
|
||||
matchChipTextToLabel,
|
||||
parseMentionClipboardRecords,
|
||||
selectBindableMentionIdentities,
|
||||
selectVisibleMentionIdentities,
|
||||
} from "./mentionClipboard.ts";
|
||||
|
||||
const JOHN = "a".repeat(64);
|
||||
const ALEX = "b".repeat(64);
|
||||
const ALEX_KIM = "c".repeat(64);
|
||||
const FIZZ = "d".repeat(64);
|
||||
|
||||
const john = { label: "John Smith", pubkey: JOHN, isAgent: false };
|
||||
|
||||
// ── buildMentionClipboardHtml ─────────────────────────────────────────
|
||||
|
||||
test("wraps a multi-word mention with its identity", () => {
|
||||
const html = buildMentionClipboardHtml({
|
||||
text: "@John Smith fixed the bug",
|
||||
identities: [john],
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
html,
|
||||
'<span data-buzz-copy="markdown">' +
|
||||
`<span data-mention="" data-mention-pubkey="${JOHN}" ` +
|
||||
'data-mention-kind="human" data-mention-label="John Smith">' +
|
||||
"@John Smith</span> fixed the bug</span>",
|
||||
);
|
||||
});
|
||||
|
||||
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 & <b> @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, /<br>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('<span data-buzz-copy="markdown">hi</span>'),
|
||||
"markdown",
|
||||
);
|
||||
assert.equal(getBuzzCopyKind('<div data-buzz-copy="rich">hi</div>'), "rich");
|
||||
assert.equal(getBuzzCopyKind("<p>hi</p>"), null);
|
||||
assert.equal(getBuzzCopyKind('<span data-buzz-copy="other">hi</span>'), 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(
|
||||
`<span data-mention-label='Jo & Ann' data-mention-kind='human' ` +
|
||||
`data-mention-pubkey='${JOHN}' data-mention="">@Jo & Ann</span>`,
|
||||
);
|
||||
|
||||
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(
|
||||
`<span data-mention-pubkey="${pubkey}" data-mention-label="John">@John</span>`,
|
||||
),
|
||||
[],
|
||||
`expected ${pubkey} to be rejected`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("normalizes pubkey casing on both sides of the round trip", () => {
|
||||
const upper = "A".repeat(64);
|
||||
assert.deepEqual(
|
||||
parseMentionClipboardRecords(
|
||||
`<span data-mention-pubkey="${upper}" data-mention-label="John">@John</span>`,
|
||||
),
|
||||
[{ 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(
|
||||
`<span data-mention-pubkey="${JOHN}">@John</span>`,
|
||||
),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseMentionClipboardRecords(
|
||||
`<span data-mention-pubkey="${JOHN}" data-mention-label="${"x".repeat(201)}">@x</span>`,
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("caps how many records one paste can register", () => {
|
||||
const spans = Array.from(
|
||||
{ length: 80 },
|
||||
(_unused, index) =>
|
||||
`<span data-mention-pubkey="${index.toString(16).padStart(64, "0")}" ` +
|
||||
`data-mention-label="User ${index}">@User ${index}</span>`,
|
||||
).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("<p>data-mention-pubkey is a string</p>"),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
// ── selectVisibleMentionIdentities ────────────────────────────────────
|
||||
|
||||
const fizz = { label: "Fizz", pubkey: FIZZ, isAgent: true };
|
||||
|
||||
test("keeps identities the inserted content mentions", () => {
|
||||
assert.deepEqual(
|
||||
selectVisibleMentionIdentities([john, fizz], "@John Smith fixed the bug"),
|
||||
[john],
|
||||
);
|
||||
});
|
||||
|
||||
test("drops an identity the inserted content never mentions", () => {
|
||||
// The hostile shape: a hidden record on copied HTML would otherwise rebind
|
||||
// a real member's display name for the rest of the composer session.
|
||||
assert.deepEqual(selectVisibleMentionIdentities([john], "look at this"), []);
|
||||
// The bare name is not a mention — only the sigil form binds.
|
||||
assert.deepEqual(
|
||||
selectVisibleMentionIdentities([john], "John Smith fixed the bug"),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("holds a partial label to the same standard as the extractor", () => {
|
||||
assert.deepEqual(
|
||||
selectVisibleMentionIdentities([john], "@John fixed it"),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores a mention the extractor would mask as code", () => {
|
||||
assert.deepEqual(selectVisibleMentionIdentities([john], "`@John Smith`"), []);
|
||||
assert.deepEqual(
|
||||
selectVisibleMentionIdentities([john], "```\n@John Smith\n```"),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
// ── selectBindableMentionIdentities ───────────────────────────────────
|
||||
|
||||
/** A verifier that vouches for every pair — isolates the other two gates. */
|
||||
const vouchForAll = async (records) => records;
|
||||
|
||||
test("keeps each recovered pair with its agent flag", async () => {
|
||||
assert.deepEqual(
|
||||
await selectBindableMentionIdentities({
|
||||
html: buildMentionClipboardHtml({
|
||||
text: "@John Smith and @Fizz",
|
||||
identities: [john, fizz],
|
||||
}),
|
||||
text: "@John Smith and @Fizz",
|
||||
verifyMentionIdentities: vouchForAll,
|
||||
}),
|
||||
[john, fizz],
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps nothing for a record the paste does not show", async () => {
|
||||
// A crafted sidecar: an empty span rebinding a name the content never
|
||||
// carries, riding alongside one the user can actually see.
|
||||
assert.deepEqual(
|
||||
await selectBindableMentionIdentities({
|
||||
html:
|
||||
`<span data-mention="" data-mention-pubkey="${ALEX}" ` +
|
||||
`data-mention-label="John Smith"></span>` +
|
||||
`<span data-mention="" data-mention-pubkey="${FIZZ}" ` +
|
||||
`data-mention-kind="agent" data-mention-label="Fizz">@Fizz</span>`,
|
||||
text: "@Fizz take a look",
|
||||
verifyMentionIdentities: vouchForAll,
|
||||
}),
|
||||
[fizz],
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps nothing for a visible pair trusted state will not vouch for", async () => {
|
||||
// The shape a hostile page carries: a plausible name against a key of its
|
||||
// choosing, written where the user *does* see it. Visibility is not the
|
||||
// question here — the verifier declining it is.
|
||||
const impostor = { label: "John Smith", pubkey: ALEX, isAgent: false };
|
||||
const asked = [];
|
||||
assert.deepEqual(
|
||||
await selectBindableMentionIdentities({
|
||||
html: buildMentionClipboardHtml({
|
||||
text: "@John Smith fixed the bug",
|
||||
identities: [impostor],
|
||||
}),
|
||||
text: "@John Smith fixed the bug",
|
||||
verifyMentionIdentities: async (records) => {
|
||||
asked.push(...records);
|
||||
return [];
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
// The visible pair still reached the verifier: it is the trust answer that
|
||||
// dropped it, not an earlier gate quietly doing the work.
|
||||
assert.deepEqual(asked, [impostor]);
|
||||
});
|
||||
|
||||
test("binds only what it asked about, whatever the verifier returns", async () => {
|
||||
// The verifier is a seam, not an authority: a bug or a future implementation
|
||||
// that answers with a pair nobody copied must not widen the paste.
|
||||
assert.deepEqual(
|
||||
await selectBindableMentionIdentities({
|
||||
html: buildMentionClipboardHtml({
|
||||
text: "@John Smith fixed the bug",
|
||||
identities: [john],
|
||||
}),
|
||||
text: "@John Smith fixed the bug",
|
||||
verifyMentionIdentities: async (records) => [
|
||||
...records,
|
||||
{ label: "John Smith", pubkey: ALEX, isAgent: false },
|
||||
],
|
||||
}),
|
||||
[john],
|
||||
);
|
||||
});
|
||||
|
||||
test("does not consult the verifier when nothing is visible", async () => {
|
||||
let consulted = false;
|
||||
assert.deepEqual(
|
||||
await selectBindableMentionIdentities({
|
||||
html:
|
||||
`<span data-mention="" data-mention-pubkey="${ALEX}" ` +
|
||||
`data-mention-label="John Smith"></span>`,
|
||||
text: "look at this",
|
||||
verifyMentionIdentities: async () => {
|
||||
consulted = true;
|
||||
return [];
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
assert.equal(consulted, false, "a hidden record must cost no lookup");
|
||||
});
|
||||
|
||||
// ── matchChipTextToLabel ──────────────────────────────────────────────
|
||||
|
||||
test("accepts a full chip, with or without the sigil written back", () => {
|
||||
assert.equal(matchChipTextToLabel("John Smith", "John Smith", "@"), "full");
|
||||
assert.equal(matchChipTextToLabel("@John Smith", "John Smith", "@"), "full");
|
||||
assert.equal(matchChipTextToLabel("#general", "general", "#"), "full");
|
||||
});
|
||||
|
||||
test("accepts the author's casing and pasteboard whitespace", () => {
|
||||
// `buildMentionSpanHtml` preserves the run as written, not the label's case.
|
||||
assert.equal(matchChipTextToLabel("@john smith", "John Smith", "@"), "full");
|
||||
// A pasteboard round trip can pad the markup or swap spaces for U+00A0.
|
||||
assert.equal(matchChipTextToLabel(" John Smith ", "John Smith", "@"), "full");
|
||||
assert.equal(
|
||||
matchChipTextToLabel("John\u00a0Smith", "John Smith", "@"),
|
||||
"full",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects the fragment a boundary-crossing selection leaves behind", () => {
|
||||
// The browser's default copy keeps the chip's attributes around whatever
|
||||
// slice of its text the selection covered — from either end.
|
||||
assert.equal(matchChipTextToLabel("John", "John Smith", "@"), "fragment");
|
||||
assert.equal(matchChipTextToLabel("Smith", "John Smith", "@"), "fragment");
|
||||
assert.equal(matchChipTextToLabel("", "John Smith", "@"), "fragment");
|
||||
});
|
||||
|
||||
test("rejects a nonempty fragment of an empty declared label", () => {
|
||||
assert.equal(matchChipTextToLabel("John", "", "@"), "fragment");
|
||||
assert.equal(matchChipTextToLabel("", "", "@"), "full");
|
||||
});
|
||||
|
||||
test("accepts the ellipsized text a chip past the length cap renders", () => {
|
||||
// A long channel name renders truncated while `data-channel-label` still
|
||||
// declares it in full, so a *whole* chip's text is not the label. Reading
|
||||
// that as a fragment would strip the identity off every copy of it.
|
||||
const label = `long-${"a".repeat(80)}-channel`;
|
||||
const rendered = truncateInlineChipLabel(label);
|
||||
assert.notEqual(rendered, label, "fixture must exceed the chip cap");
|
||||
|
||||
assert.equal(matchChipTextToLabel(rendered, label, "#"), "truncated");
|
||||
assert.equal(matchChipTextToLabel(`#${rendered}`, label, "#"), "truncated");
|
||||
// A partial selection of that same chip is still a fragment.
|
||||
assert.equal(
|
||||
matchChipTextToLabel(rendered.slice(0, 10), label, "#"),
|
||||
"fragment",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not treat an uncapped label's ellipsis as a truncation", () => {
|
||||
// "truncated" is only ever the cap's own output — a label that renders
|
||||
// whole must not gain a second accepted form.
|
||||
assert.equal(matchChipTextToLabel("John…", "John Smith", "@"), "fragment");
|
||||
});
|
||||
416
desktop/src/features/messages/lib/mentionClipboard.ts
Normal file
416
desktop/src/features/messages/lib/mentionClipboard.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { truncateInlineChipLabel } from "@/shared/ui/mentionChip";
|
||||
|
||||
import { getMentionOffsets } from "./hasMention";
|
||||
|
||||
/**
|
||||
* Dual-flavor clipboard support for mentions.
|
||||
*
|
||||
* Every Buzz copy writes two flavors:
|
||||
* - `text/plain` — what a human should read anywhere: sigils restored, no
|
||||
* pubkeys. This is what external apps (TextEdit, Slack, …) receive.
|
||||
* - `text/html` — the same content, with each mention wrapped in a span
|
||||
* carrying its pubkey. Pasting back into a Buzz composer harvests those
|
||||
* records, registers `name → pubkey` with the mention machinery, and the
|
||||
* chip re-lights with the exact tagged identity.
|
||||
*
|
||||
* The wrapper marker declares what the *plain* flavor holds so paste knows
|
||||
* which content path to use — see `BuzzCopyKind`.
|
||||
*/
|
||||
|
||||
/** Marks clipboard HTML that Buzz produced, and what its plain flavor holds. */
|
||||
export const BUZZ_COPY_ATTRIBUTE = "data-buzz-copy";
|
||||
/** 64-hex pubkey of the mentioned identity. */
|
||||
export const MENTION_PUBKEY_ATTRIBUTE = "data-mention-pubkey";
|
||||
/** `human` or `agent` — decides which highlight the re-lit chip gets. */
|
||||
export const MENTION_KIND_ATTRIBUTE = "data-mention-kind";
|
||||
/** Full mention label, so a partially selected chip is detectable. */
|
||||
export const MENTION_LABEL_ATTRIBUTE = "data-mention-label";
|
||||
/** Full channel-reference label, same partial-selection role as above. */
|
||||
export const CHANNEL_LABEL_ATTRIBUTE = "data-channel-label";
|
||||
|
||||
/**
|
||||
* Canonical form for comparing two spellings of one mention label.
|
||||
*
|
||||
* Tolerates what a label picks up in transit and nothing more: a pasteboard
|
||||
* round trip swaps spaces for U+00A0, markup gains padding, and mention
|
||||
* resolution is case-insensitive end to end. Every clipboard comparison of
|
||||
* two labels goes through here, so "the same name" means one thing across the
|
||||
* copy-side chip classifier and the paste-side trust check.
|
||||
*/
|
||||
export function canonicalMentionLabel(value: string): string {
|
||||
return value
|
||||
.replace(/\u00a0/g, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/** How a chip's copied text relates to the full label it declares. */
|
||||
export type ChipTextMatch = "full" | "truncated" | "fragment";
|
||||
|
||||
/**
|
||||
* Classify a chip's copied text against the label its attributes declare.
|
||||
*
|
||||
* Both clipboard sides ask this one question — the copy handler deciding
|
||||
* whether to write a sigil back, and the paste normalizer deciding whether to
|
||||
* keep one — so they cannot drift apart on what counts as a whole chip.
|
||||
*
|
||||
* - `full` — the text carries the whole label, allowing for what a legitimate
|
||||
* chip picks up in transit: Buzz's copy handlers write the sigil into the
|
||||
* text, `buildMentionSpanHtml` keeps the author's casing over the label's,
|
||||
* and a pasteboard round trip can swap spaces for U+00A0.
|
||||
* - `truncated` — the text is the ellipsized form `truncateInlineChipLabel`
|
||||
* renders for a label past the inline-chip cap. A *fully* selected long chip
|
||||
* carries this rather than the label, so treating it as a fragment would
|
||||
* strip the identity off every copy of a long channel reference and drop the
|
||||
* whole selection back to the browser's dead-text default.
|
||||
* - `fragment` — anything else: the slice a boundary-crossing selection leaves
|
||||
* behind. It must neither regain a sigil nor keep an identity, so that a
|
||||
* paste can never bind a real pubkey to a partial name.
|
||||
*
|
||||
* Deliberately not an equality test against the rendered text: a chip that
|
||||
* grows any text of its own (a badge, a glyph's text fallback) must not
|
||||
* silently reclassify every chip as a fragment. Text a chip adds *beyond* its
|
||||
* label still reads as `fragment` — the safe direction, since that costs a
|
||||
* copy its identity rather than inventing one.
|
||||
*/
|
||||
export function matchChipTextToLabel(
|
||||
text: string,
|
||||
label: string,
|
||||
sigil: "@" | "#",
|
||||
): ChipTextMatch {
|
||||
const body = canonicalMentionLabel(text);
|
||||
const matches = (form: string) => body === form || body === `${sigil}${form}`;
|
||||
if (matches(canonicalMentionLabel(label))) return "full";
|
||||
// Derived from the helper the chips render with, so the tolerated form
|
||||
// cannot drift from what a fully selected capped chip actually carries.
|
||||
const truncated = truncateInlineChipLabel(label);
|
||||
if (truncated !== label && matches(canonicalMentionLabel(truncated))) {
|
||||
return "truncated";
|
||||
}
|
||||
return "fragment";
|
||||
}
|
||||
|
||||
/**
|
||||
* What the `text/plain` flavor of a Buzz copy contains.
|
||||
*
|
||||
* - `markdown` — Markdown source (copy-message, composer copy/cut). Paste
|
||||
* inserts the plain flavor so TipTap's Markdown parsing behaves exactly as
|
||||
* it does for a plain-text paste.
|
||||
* - `rich` — rendered timeline HTML. Paste keeps the HTML content path.
|
||||
*/
|
||||
export type BuzzCopyKind = "markdown" | "rich";
|
||||
|
||||
/** A `name → pubkey` pair the composer can register on paste. */
|
||||
export type MentionIdentity = {
|
||||
label: string;
|
||||
pubkey: string;
|
||||
isAgent: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clipboard HTML is untrusted input — a foreign app can put anything on the
|
||||
* pasteboard. Bound the record count and label length, and require a
|
||||
* well-formed pubkey, before any of it can become an outbound `p` tag.
|
||||
*/
|
||||
const MAX_MENTION_RECORDS = 50;
|
||||
const MAX_MENTION_LABEL_LENGTH = 200;
|
||||
|
||||
const HTML_ESCAPES: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
const HTML_UNESCAPES: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
""": '"',
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
/** 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 [
|
||||
`<span data-mention=""`,
|
||||
` ${MENTION_PUBKEY_ATTRIBUTE}="${escapeClipboardHtml(identity.pubkey)}"`,
|
||||
` ${MENTION_KIND_ATTRIBUTE}="${identity.isAgent ? "agent" : "human"}"`,
|
||||
` ${MENTION_LABEL_ATTRIBUTE}="${escapeClipboardHtml(identity.label)}"`,
|
||||
`>${escapeClipboardHtml(text)}</span>`,
|
||||
].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<number, MentionMatch>();
|
||||
|
||||
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, "<br>"));
|
||||
};
|
||||
|
||||
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 `<span ${BUZZ_COPY_ATTRIBUTE}="${kind}">${parts.join("")}</span>`;
|
||||
}
|
||||
|
||||
/** 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<string>();
|
||||
|
||||
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()} | ||||