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:
Matt Toohey
2026-09-04 16:45:13 +10:00
committed by GitHub
parent 4b0744d7f3
commit b1f6b7ef77
43 changed files with 5392 additions and 165 deletions

View File

@@ -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",

View File

@@ -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;

View File

@@ -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

View File

@@ -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 ? (

View File

@@ -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,

View File

@@ -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}
/>

View 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;
}

View 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;
},
};
},
});

View 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, / &amp; &lt;b&gt; /);
});
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 &amp; Ann' data-mention-kind='human' ` +
`data-mention-pubkey='${JOHN}' data-mention="">@Jo &amp; 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");
});

View 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> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
const HTML_UNESCAPES: Record<string, string> = {
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": '"',
"&#39;": "'",
};
/** 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()}${pubkey}`;
if (seen.has(key)) continue;
seen.add(key);
records.push({
label,
pubkey,
isAgent: readAttribute(tag, MENTION_KIND_ATTRIBUTE) === "agent",
});
}
return records;
}
/**
* 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
* `<span data-mention-pubkey … data-mention-label="Jane Doe">` 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,
);
}
/**
* Narrow copied records to the pairs trusted Buzz state vouches for.
*
* Must resolve to a subset of what it was handed; callers enforce that rather
* than assume it, so the seam cannot widen into "the verifier decides what
* gets bound". See `useVerifyMentionIdentities` for the implementation and
* `mentionIdentityTrust` for why the check exists.
*/
export type VerifyMentionIdentities = (
records: readonly MentionIdentity[],
) => Promise<readonly MentionIdentity[]>;
/** Case-insensitive identity of a `label → pubkey` pair. */
function mentionIdentityKey(identity: MentionIdentity): string {
return `${canonicalMentionLabel(identity.label)} ${identity.pubkey.trim().toLowerCase()}`;
}
/**
* Narrow already-visible records to the pairs the verifier vouches for.
*
* The result is filtered back down to what was asked about, so the verifier
* stays a seam rather than an authority: a bug or a future implementation that
* answers with a pair nobody copied cannot widen the paste.
*/
export async function selectVouchedMentionIdentities(
visible: readonly MentionIdentity[],
verifyMentionIdentities: VerifyMentionIdentities,
): Promise<MentionIdentity[]> {
if (visible.length === 0) return [];
const vouched = new Set(
(await verifyMentionIdentities(visible)).map(mentionIdentityKey),
);
return visible.filter((record) => vouched.has(mentionIdentityKey(record)));
}
/**
* The identities a paste is allowed to bind.
*
* Three conditions, all necessary. The clipboard has to *carry* the record;
* the content the paste inserts has to *show* its label, so no binding
* outlives a paste the user could not see; and trusted Buzz state has to
* *vouch* for the pair, because a visible `@John Smith` beside an attacker's
* key is visible either way.
*
* Binding 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. Everything dropped here stays
* readable text that tags nobody.
*
* The two halves are separately exported because the binder needs the first
* one *synchronously*, at paste time, to claim each label it is about to
* verify — see `useMentionPasteBinding`.
*/
export async function selectBindableMentionIdentities({
html,
text,
verifyMentionIdentities,
}: {
/** Clipboard HTML holding the identity records — untrusted. */
html: string;
/** The text the paste inserts; a record unmentioned there is discarded. */
text: string;
verifyMentionIdentities: VerifyMentionIdentities;
}): Promise<MentionIdentity[]> {
return selectVouchedMentionIdentities(
selectVisibleMentionIdentities(parseMentionClipboardRecords(html), text),
verifyMentionIdentities,
);
}

View File

@@ -0,0 +1,82 @@
import type { EditorView } from "@tiptap/pm/view";
import { getBuzzCopyKind } from "./mentionClipboard";
import type { BindPastedMentionIdentities } from "./mentionPasteBinding";
import { normalizeMentionClipboardContent } from "./normalizeMentionClipboard";
/**
* 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. That re-entry also means this
* function's own call carries no identity records, so it cannot double-bind
* the identities its caller is about to hand over.
*/
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.
*
* 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: binding 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 is judged against the content *it* inserts
* — the plain flavor is not evidence for what the HTML branch shows, and vice
* versa — and the range it inserted into is handed over, so a verification
* that lands later can tell the mention tokens this paste put on screen from
* whatever the user typed next. `bindPastedMentionIdentities` owns the rest; a
* composer that passes no binder inserts readable text and binds nothing.
*/
export function handleMentionClipboardPaste({
bindMentionIdentities,
clipboardData,
preventDefault,
view,
}: {
bindMentionIdentities?: BindPastedMentionIdentities;
clipboardData: DataTransfer;
preventDefault: () => void;
view: EditorView;
}): boolean {
const html = clipboardData.getData("text/html");
if (!html) return false;
// Captured before the insertion; `pasteText`/`pasteHTML` dispatch
// synchronously, so the caret afterwards closes the range this paste owns.
const insertedFrom = view.state.selection.from;
const bind = (insertedText: string) => {
if (!bindMentionIdentities) return;
bindMentionIdentities({
html,
insertedText,
insertedRange: { from: insertedFrom, to: view.state.selection.to },
view,
});
};
const text = clipboardData.getData("text/plain");
if (getBuzzCopyKind(html) === "markdown" && text) {
preventDefault();
pastePlainText(view, text);
bind(text);
return true;
}
const content = normalizeMentionClipboardContent(html);
preventDefault();
view.pasteHTML(content.html);
bind(content.text);
return true;
}

View File

@@ -0,0 +1,67 @@
import {
canonicalMentionLabel,
type MentionIdentity,
} from "./mentionClipboard";
/**
* Whether trusted Buzz state vouches for a copied `label → pubkey` pair.
*
* Clipboard HTML is attacker-authored: any page can carry
* `<span data-mention-pubkey="<their key>" data-mention-label="John Smith">
* @John Smith</span>`, and that pastes as a plausible, *visible* mention.
* Visibility only proves the user saw a name — not that the name belongs to
* the key beside it. A marker Buzz writes proves less still, since an attacker
* can write the same marker.
*
* So the pair itself has to be checked against state the community supplied:
* the pubkey's own profile aliases, or a directory entry naming it. A pair
* nothing vouches for binds nothing — the words paste as readable text and no
* `p` tag carries the key. That is the fail-closed direction: the cost is a
* mention that stays plain, never an outbound tag naming the wrong person.
*/
/**
* Does `label` name one of `aliases`?
*
* Compared through `canonicalMentionLabel`, so a legitimate copy is not
* refused over the casing, padding, or U+00A0 a pasteboard round trip leaves
* on the declared label.
*/
export function isTrustedMentionLabel(
label: string,
aliases: Iterable<string>,
): boolean {
const wanted = canonicalMentionLabel(label);
if (!wanted) return false;
for (const alias of aliases) {
if (canonicalMentionLabel(alias) === wanted) return true;
}
return false;
}
/**
* Split records by whether locally held trusted state already vouches for
* them, so only the remainder costs a relay round trip.
*
* `resolveLocalAliases` answers for a normalized pubkey with every name local
* trusted state knows it by. An empty answer is "not known here", never
* "refuted" — the caller escalates those to the relay, which is the only
* source that can speak for a pubkey no local directory has seen.
*/
export function partitionMentionIdentitiesByLocalTrust(
records: readonly MentionIdentity[],
resolveLocalAliases: (pubkey: string) => readonly string[],
): { trusted: MentionIdentity[]; unresolved: MentionIdentity[] } {
const trusted: MentionIdentity[] = [];
const unresolved: MentionIdentity[] = [];
for (const record of records) {
if (
isTrustedMentionLabel(record.label, resolveLocalAliases(record.pubkey))
) {
trusted.push(record);
} else {
unresolved.push(record);
}
}
return { trusted, unresolved };
}

View File

@@ -0,0 +1,445 @@
import assert from "node:assert/strict";
import { after, afterEach, before, test } from "node:test";
import { getSchema } from "@tiptap/core";
import { EditorState } from "@tiptap/pm/state";
import StarterKit from "@tiptap/starter-kit";
import { JSDOM } from "jsdom";
import { extractMentionPubkeys } from "./extractMentionPubkeys.ts";
import { PastedMentionOccurrencesExtension } from "./pastedMentionOccurrences.ts";
/**
* The three fences a settled paste has to clear, driven through the hook the
* composers actually use.
*
* Verification is deferred by hand here rather than timed, so each case pins
* an ordering rather than a race: a paste whose answer is still outstanding, a
* newer intent for the same label, and a mention token the user has since
* edited. The mention map and `extractMentionPubkeys` are the real ones
* `useMentions` writes to and reads with, so what these assert is what a send
* would put in its `p` tags.
*
* The occurrence fence is the `@Label` token rather than the whole insertion,
* and that boundary cuts both ways: rewriting the mention itself must cost the
* paste its identity even though the sentence around it is untouched, and
* rewriting the sentence must *not*, since the slow-verifying non-member case
* is the one the feature exists for.
*/
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
/** 64-hex, the only shape `parseMentionClipboardRecords` lets through. */
const KEY_A = "a".repeat(64);
const KEY_B = "b".repeat(64);
const PASTED = "@John Smith fixed the bug";
const SECOND_PASTE = " and @John Smith agrees";
const TOKEN = "@John Smith";
/** A paste with text either side of its mention, as most copies have. */
const SENTENCE = `Hello ${TOKEN} fixed the bug`;
before(() => {
Object.assign(globalThis, {
document: dom.window.document,
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
window: dom.window,
});
});
afterEach(async () => {
const { cleanup } = await import("@testing-library/react");
cleanup();
});
after(() => dom.window.close());
const schema = getSchema([
StarterKit.configure({ heading: false, trailingNode: false, link: false }),
]);
const text = (value) => schema.text(value);
const document_ = (value) =>
schema.nodes.doc.create(null, [
schema.nodes.paragraph.create(null, [text(value)]),
]);
/** A stand-in for `EditorView` over a real `EditorState` and real plugin. */
function viewWith(initialText) {
const view = {
state: EditorState.create({
doc: document_(initialText),
schema,
plugins:
PastedMentionOccurrencesExtension.config.addProseMirrorPlugins.call({}),
}),
dispatch(tr) {
view.state = view.state.apply(tr);
},
};
return view;
}
/** Clipboard HTML in the shape a Buzz copy writes. */
function clipboardHtml(label, pubkey, body) {
return (
'<span data-buzz-copy="markdown">' +
`<span data-mention="" data-mention-pubkey="${pubkey}" ` +
`data-mention-label="${label}">@${label}</span>` +
`${body}</span>`
);
}
function deferred() {
let resolve;
const promise = new Promise((settle) => {
resolve = settle;
});
return { promise, resolve };
}
/**
* Render the binder with the mention map `useMentions` keeps, and a verifier
* whose answers the test releases one at a time.
*/
async function renderBinder() {
const { renderHook } = await import("@testing-library/react");
const { useMentionPasteBinding } = await import("./mentionPasteBinding.ts");
/** Stands in for `mentionMapRef.current`; the writer mirrors the hook's. */
const mentionMap = new Map();
const answers = [];
const { result } = renderHook(() =>
useMentionPasteBinding({
registerVerifiedMentionPubkey: (displayName, pubkey) => {
mentionMap.set(displayName.trim(), pubkey);
},
verifyMentionIdentities: () => {
const next = deferred();
answers.push(next);
return next.promise;
},
}),
);
return {
/** The pubkeys a send would tag for `body`. */
extract: (body) =>
extractMentionPubkeys({
text: body,
selectedMentions: mentionMap,
selectedDisplayNames: [],
memberCandidates: [],
}),
mentionMap,
/** Answer the nth outstanding verification, oldest first. */
vouch: (index, identities) => answers[index].resolve(identities),
get binding() {
return result.current;
},
};
}
/** Paste `body`'s records into `view` over the range production hands over. */
function paste(binding, view, { label, pubkey, body, from, to }) {
binding.bindPastedMentionIdentities({
html: clipboardHtml(label, pubkey, body.slice(`@${label}`.length)),
insertedText: body,
insertedRange: { from, to },
view,
});
}
/** Paste a whole paragraph the view was built holding. */
function pasteWholeParagraph(binding, view, { label, pubkey, body }) {
paste(binding, view, {
label,
pubkey,
body,
from: 1,
to: 1 + body.length,
});
}
/** Replace `[at, at + length)` the way a select-and-retype does. */
function replaceRange(view, at, length, replacement) {
view.dispatch(view.state.tr.replaceWith(at, at + length, text(replacement)));
}
test("a pasted identity binds only once its verification has settled", async () => {
// The send seams await `settlePendingMentionBindings` precisely because this
// window exists: sending inside it publishes a readable label with no tag.
const harness = await renderBinder();
const view = viewWith(PASTED);
paste(harness.binding, view, {
label: "John Smith",
pubkey: KEY_A,
body: PASTED,
from: 1,
to: 1 + PASTED.length,
});
assert.deepEqual(harness.extract(PASTED), [], "nothing binds mid-flight");
const drained = harness.binding.settlePendingMentionBindings();
harness.vouch(0, [{ label: "John Smith", pubkey: KEY_A, isAgent: false }]);
await drained;
assert.deepEqual(harness.extract(PASTED), [KEY_A]);
});
test("a settled paste does not overwrite a newer paste of the same label", async () => {
// Slow A, fast B, same label. Both occurrences stay alive, so ordering — not
// visibility — is the only thing that can decide which pubkey owns the name.
const harness = await renderBinder();
const view = viewWith(PASTED + SECOND_PASTE);
paste(harness.binding, view, {
label: "John Smith",
pubkey: KEY_A,
body: PASTED,
from: 1,
to: 1 + PASTED.length,
});
paste(harness.binding, view, {
label: "John Smith",
pubkey: KEY_B,
body: SECOND_PASTE,
from: 1 + PASTED.length,
to: 1 + PASTED.length + SECOND_PASTE.length,
});
harness.vouch(1, [{ label: "John Smith", pubkey: KEY_B, isAgent: false }]);
harness.vouch(0, [{ label: "John Smith", pubkey: KEY_A, isAgent: false }]);
await harness.binding.settlePendingMentionBindings();
assert.deepEqual(harness.extract(PASTED), [KEY_B]);
});
test("an explicit selection outranks a paste still being verified", async () => {
// What the picker and every other `registerMentionPubkey` caller do: claim
// the label, then write it. A paste that resolves afterwards is stale.
const harness = await renderBinder();
const view = viewWith(PASTED);
paste(harness.binding, view, {
label: "John Smith",
pubkey: KEY_A,
body: PASTED,
from: 1,
to: 1 + PASTED.length,
});
harness.binding.claimMentionIntent("John Smith");
harness.mentionMap.set("John Smith", KEY_B);
harness.vouch(0, [{ label: "John Smith", pubkey: KEY_A, isAgent: false }]);
await harness.binding.settlePendingMentionBindings();
assert.deepEqual(harness.extract(PASTED), [KEY_B]);
});
test("a paste whose text is gone binds nothing, label elsewhere or not", async () => {
// Delete the paste, then write the same name by hand. "Is this label in the
// composer?" says yes; the paste no longer owns any of it.
const harness = await renderBinder();
const view = viewWith(PASTED);
paste(harness.binding, view, {
label: "John Smith",
pubkey: KEY_A,
body: PASTED,
from: 1,
to: 1 + PASTED.length,
});
const pastedEnd = 1 + PASTED.length;
view.dispatch(view.state.tr.insertText(SECOND_PASTE, pastedEnd));
view.dispatch(view.state.tr.delete(1, pastedEnd));
assert.equal(view.state.doc.textContent, SECOND_PASTE);
harness.vouch(0, [{ label: "John Smith", pubkey: KEY_A, isAgent: false }]);
await harness.binding.settlePendingMentionBindings();
assert.deepEqual(harness.extract(SECOND_PASTE), []);
});
test("rewriting the pasted mention itself binds nothing to the typed words", async () => {
// Select exactly the mention and type it out again. The edit is strictly
// inside the paste, so both of the insertion's endpoints survive and its
// text is character-for-character what it was — the whole-insertion fence
// saw nothing at all, and handed the clipboard's key to hand-typed words.
const harness = await renderBinder();
const view = viewWith(SENTENCE);
pasteWholeParagraph(harness.binding, view, {
label: "John Smith",
pubkey: KEY_A,
body: SENTENCE,
});
replaceRange(view, 1 + SENTENCE.indexOf(TOKEN), TOKEN.length, TOKEN);
assert.equal(view.state.doc.textContent, SENTENCE);
harness.vouch(0, [{ label: "John Smith", pubkey: KEY_A, isAgent: false }]);
await harness.binding.settlePendingMentionBindings();
assert.deepEqual(harness.extract(SENTENCE), []);
});
test("editing a word beside the pasted mention keeps its identity", async () => {
// The payoff for fencing the token rather than the insertion: a lookup that
// crosses the network is exactly the case the user has time to tidy the
// sentence during, and tidying it must not silently cost the mention.
const harness = await renderBinder();
const view = viewWith(SENTENCE);
pasteWholeParagraph(harness.binding, view, {
label: "John Smith",
pubkey: KEY_A,
body: SENTENCE,
});
const at = 1 + SENTENCE.indexOf("fixed ");
view.dispatch(view.state.tr.delete(at, at + "fixed ".length));
const edited = SENTENCE.replace("fixed ", "");
assert.equal(view.state.doc.textContent, edited);
harness.vouch(0, [{ label: "John Smith", pubkey: KEY_A, isAgent: false }]);
await harness.binding.settlePendingMentionBindings();
assert.deepEqual(harness.extract(edited), [KEY_A]);
});
test("rewriting one occurrence leaves the label its other one", async () => {
// Each occurrence is fenced on its own, and the name is still on screen off
// this paste — so one of them being retyped is not the label's whole claim.
const body = `${TOKEN} and ${TOKEN} again`;
const harness = await renderBinder();
const view = viewWith(body);
pasteWholeParagraph(harness.binding, view, {
label: "John Smith",
pubkey: KEY_A,
body,
});
replaceRange(view, 1, TOKEN.length, TOKEN);
assert.equal(view.state.doc.textContent, body);
harness.vouch(0, [{ label: "John Smith", pubkey: KEY_A, isAgent: false }]);
await harness.binding.settlePendingMentionBindings();
assert.deepEqual(harness.extract(body), [KEY_A]);
});
test("rewriting part of the pasted mention binds nothing", async () => {
// Select the first name inside the token and type it again. Neither of the
// token's endpoints is touched, so endpoint mapping alone reports a whole
// live mention over characters the user wrote — and the text check agrees
// with it, because the document reads exactly as it did.
const harness = await renderBinder();
const view = viewWith(SENTENCE);
pasteWholeParagraph(harness.binding, view, {
label: "John Smith",
pubkey: KEY_A,
body: SENTENCE,
});
replaceRange(view, 1 + SENTENCE.indexOf("John"), "John".length, "John");
assert.equal(view.state.doc.textContent, SENTENCE);
harness.vouch(0, [{ label: "John Smith", pubkey: KEY_A, isAgent: false }]);
await harness.binding.settlePendingMentionBindings();
assert.deepEqual(harness.extract(SENTENCE), []);
});
test("typing inside the pasted mention binds nothing", async () => {
// A pure insertion replaces nothing, so the tracked range survives it by
// design. What refuses this is the token's own text no longer reading as a
// mention of the label the clipboard named.
const harness = await renderBinder();
const view = viewWith(SENTENCE);
pasteWholeParagraph(harness.binding, view, {
label: "John Smith",
pubkey: KEY_A,
body: SENTENCE,
});
view.dispatch(view.state.tr.insertText("y", 1 + SENTENCE.indexOf("Smith")));
assert.equal(
view.state.doc.textContent,
SENTENCE.replace(TOKEN, "@John ySmith"),
);
harness.vouch(0, [{ label: "John Smith", pubkey: KEY_A, isAgent: false }]);
await harness.binding.settlePendingMentionBindings();
// The map entry is the real damage: nothing shows the label now, but a
// binding outlives its paste and would light the next one typed by hand.
assert.equal(harness.mentionMap.has("John Smith"), false);
});
test("typing against the pasted mention's edge binds nothing", async () => {
// Text typed at either edge lands outside the tracked range on purpose —
// and it is exactly the text that destroys the word boundary a mention
// needs, so the range reads whole while the document shows no mention.
const harness = await renderBinder();
const view = viewWith(SENTENCE);
pasteWholeParagraph(harness.binding, view, {
label: "John Smith",
pubkey: KEY_A,
body: SENTENCE,
});
view.dispatch(
view.state.tr.insertText("x", 1 + SENTENCE.indexOf(TOKEN) + TOKEN.length),
);
assert.equal(
view.state.doc.textContent,
SENTENCE.replace(TOKEN, `${TOKEN}x`),
);
harness.vouch(0, [{ label: "John Smith", pubkey: KEY_A, isAgent: false }]);
await harness.binding.settlePendingMentionBindings();
assert.equal(harness.mentionMap.has("John Smith"), false);
});
test("clearing the composer's mentions retires an in-flight paste", async () => {
const harness = await renderBinder();
const view = viewWith(PASTED);
paste(harness.binding, view, {
label: "John Smith",
pubkey: KEY_A,
body: PASTED,
from: 1,
to: 1 + PASTED.length,
});
harness.binding.clearMentionIntents();
harness.vouch(0, [{ label: "John Smith", pubkey: KEY_A, isAgent: false }]);
await harness.binding.settlePendingMentionBindings();
assert.deepEqual(harness.extract(PASTED), []);
});
test("a hidden record costs no verification and binds nothing", async () => {
const harness = await renderBinder();
const view = viewWith("look at this");
harness.binding.bindPastedMentionIdentities({
html:
`<span data-mention="" data-mention-pubkey="${KEY_A}" ` +
'data-mention-label="John Smith"></span>look at this',
insertedText: "look at this",
insertedRange: { from: 1, to: 1 + "look at this".length },
view,
});
// Nothing to settle: the sync visibility gate declined it before any lookup.
await harness.binding.settlePendingMentionBindings();
assert.deepEqual(harness.extract(PASTED), []);
});
test("draining is a no-op when nothing is pending", async () => {
const harness = await renderBinder();
await harness.binding.settlePendingMentionBindings();
});

View File

@@ -0,0 +1,298 @@
import * as React from "react";
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
import {
canonicalMentionLabel,
parseMentionClipboardRecords,
selectVisibleMentionIdentities,
selectVouchedMentionIdentities,
type MentionIdentity,
type VerifyMentionIdentities,
} from "./mentionClipboard";
import { findMentionTokenSpans } from "./mentionTokenSpans";
import {
MAX_TRACKED_OCCURRENCES,
readPastedMentionOccurrenceRange,
releasePastedMentionOccurrence,
trackPastedMentionOccurrence,
type PastedMentionOccurrenceView,
} from "./pastedMentionOccurrences";
/** Writes one `name → pubkey` pair into the composer's mention map. */
export type RegisterMentionPubkey = (
displayName: string,
pubkey: string,
options?: { isAgent?: boolean },
) => void;
/** What a composer's paste handler hands over for identity binding. */
export type BindPastedMentionIdentities = (input: {
/** Clipboard HTML holding the identity records — untrusted. */
html: string;
/** The text this paste inserted, as the mention matchers read it. */
insertedText: string;
/** Where the paste landed; the mention tokens inside it get fenced. */
insertedRange: { from: number; to: number };
view: PastedMentionOccurrenceView;
}) => void;
export type MentionPasteBinding = {
bindPastedMentionIdentities: BindPastedMentionIdentities;
/**
* Record explicit user intent for a label, retiring any pending paste that
* claimed it. Every caller is a deliberate act — a picker selection, a
* resolved insert, a send-time persona registration.
*/
claimMentionIntent: (label: string) => void;
/** Drop every claim, so anything still in flight settles into nothing. */
clearMentionIntents: () => void;
/** Resolve once no paste verification is still deciding what to bind. */
settlePendingMentionBindings: () => Promise<void>;
};
/**
* How long a send waits on an in-flight paste verification.
*
* Generous, because the wait is bounded by a relay round trip the user cannot
* see and the alternative is silently dropping the identity they copied. On
* expiry the send proceeds with what the composer truthfully shows: the chip
* never lit, so plain text sends as plain text.
*/
export const PENDING_MENTION_BINDING_TIMEOUT_MS = 10_000;
/** Same bound as the mention maps this feeds. */
const MAX_TRACKED_INTENTS = 200;
/**
* Track a range per mention token this paste put on screen.
*
* Keyed by canonical label, because that is what a settlement has in hand: one
* record can be shown twice, and either occurrence surviving means the paste
* still shows the name. The record cap bounds distinct labels but not their
* occurrences, so the plugin's own ceiling bounds this too — dropping the
* tokens furthest into the paste costs an identity rather than binding one.
*/
function trackPastedMentionTokens(
view: PastedMentionOccurrenceView,
insertedRange: { from: number; to: number },
records: readonly MentionIdentity[],
): Map<string, number[]> {
const spans = findMentionTokenSpans(
view.state.doc,
insertedRange,
records.map((record) => record.label),
).slice(0, MAX_TRACKED_OCCURRENCES);
const tracked = new Map<string, number[]>();
for (const span of spans) {
const id = trackPastedMentionOccurrence(view, span.from, span.to);
if (id === null) continue;
const key = canonicalMentionLabel(span.label);
const ids = tracked.get(key);
if (ids) ids.push(id);
else tracked.set(key, [id]);
}
return tracked;
}
/**
* Whether one of this paste's tokens still reads as a mention of `label`.
*
* Two questions, and a settlement needs both. The tracked range has to still
* exist — an edit that deleted or replaced the token retires it — and the
* document at that range has to still *be* a mention, which is what catches
* the edits a range survives by design: characters typed inside the token
* leave both endpoints intact, and characters typed against its outer edge
* land outside the range while destroying the word boundary a mention needs.
* Asking `findMentionTokenSpans` for a character either side is what puts that
* boundary back in view, since the token read alone always supplies one.
*/
function ownsLiveMentionToken(
view: PastedMentionOccurrenceView,
ids: readonly number[] | undefined,
label: string,
): boolean {
if (!ids) return false;
return ids.some((id) => {
const range = readPastedMentionOccurrenceRange(view, id);
if (!range) return false;
return findMentionTokenSpans(
view.state.doc,
{ from: range.from - 1, to: range.to + 1 },
[label],
).some((span) => span.from === range.from && span.to === range.to);
});
}
/**
* Bind the identities a paste is entitled to, once they check out.
*
* Verification can need a relay round trip, so the answer lands after the
* insertion — which makes settlement, not the paste, the moment that has to
* establish it is still writing what the user meant. Three fences do that,
* one per way an unfenced settlement went wrong:
*
* - **Occurrence.** The `@Label` token *this* paste inserted must still be
* there, in the range it landed in (see `pastedMentionOccurrences`).
* Deleting the paste and hand-typing the same name previously bound the
* clipboard's pubkey to the typed text — and so did retyping the mention on
* its own, once the fence was the whole insertion rather than the token.
* - **Generation.** The label's newest claim must still be this paste's. Two
* pastes of one label, or a picker selection made mid-flight, previously let
* whichever verification finished *last* own the name.
* - **Trust.** The pair must be one this community's own state vouches for —
* `selectVouchedMentionIdentities`, unchanged by the fences above.
*
* A binding only matters at send time, and `settlePendingMentionBindings` is
* what makes that true: the send seams await it, so a still-deciding paste
* cannot publish a readable `@Label` with no `p` tag.
*/
export function useMentionPasteBinding({
registerVerifiedMentionPubkey,
verifyMentionIdentities,
}: {
/** The non-bumping map write: settlement is not fresh user intent. */
registerVerifiedMentionPubkey: RegisterMentionPubkey;
verifyMentionIdentities: VerifyMentionIdentities;
}): MentionPasteBinding {
const registerRef = React.useRef(registerVerifiedMentionPubkey);
registerRef.current = registerVerifiedMentionPubkey;
const verifyRef = React.useRef(verifyMentionIdentities);
verifyRef.current = verifyMentionIdentities;
// Newest claim per label, and the counter it is drawn from. A settlement
// compares its own claim against the current one, so ordering decides
// ownership rather than arrival.
const intentsRef = React.useRef<Map<string, number>>(new Map());
const intentCounterRef = React.useRef(0);
const pendingRef = React.useRef<Set<Promise<void>>>(new Set());
const claimIntent = React.useCallback((label: string): number | null => {
const key = canonicalMentionLabel(label);
if (!key) return null;
const generation = ++intentCounterRef.current;
// Re-insert so the bound below evicts by recency; an evicted claim reads
// as "not current" at settlement, which is the fail-closed direction.
intentsRef.current.delete(key);
intentsRef.current.set(key, generation);
trimMapToSize(intentsRef.current, MAX_TRACKED_INTENTS);
return generation;
}, []);
const claimMentionIntent = React.useCallback(
(label: string) => {
claimIntent(label);
},
[claimIntent],
);
const clearMentionIntents = React.useCallback(() => {
intentsRef.current.clear();
}, []);
const bindPastedMentionIdentities =
React.useCallback<BindPastedMentionIdentities>(
({ html, insertedText, insertedRange, view }) => {
const visible = selectVisibleMentionIdentities(
parseMentionClipboardRecords(html),
insertedText,
);
if (visible.length === 0) return;
// Claimed synchronously, before anything can be awaited: a paste that
// lands later must be able to see that it outranks this one.
const claims = new Map<string, number | null>();
for (const record of visible) {
claims.set(
canonicalMentionLabel(record.label),
claimIntent(record.label),
);
}
const tracked = trackPastedMentionTokens(view, insertedRange, visible);
// Nothing tracked is nothing a late answer could be held to, so the
// lookup is not worth spending: the words stay plain and tag nobody.
if (tracked.size === 0) return;
const settle = async () => {
try {
const vouched = await selectVouchedMentionIdentities(
visible,
verifyRef.current,
);
for (const record of vouched) {
const key = canonicalMentionLabel(record.label);
if (intentsRef.current.get(key) !== claims.get(key)) continue;
if (!ownsLiveMentionToken(view, tracked.get(key), record.label)) {
continue;
}
registerRef.current(record.label, record.pubkey, {
isAgent: record.isAgent,
});
}
} catch (error) {
// Nothing is orphaned by giving up here — the pasted words are
// already in the composer and simply stay plain. Retrying an
// identity lookup the user never asked for would be the surprising
// behaviour.
console.warn("Could not verify pasted mention identities", error);
} finally {
for (const ids of tracked.values()) {
for (const id of ids) releasePastedMentionOccurrence(view, id);
}
}
};
const settlement = settle().finally(() => {
pendingRef.current.delete(settlement);
});
pendingRef.current.add(settlement);
},
[claimIntent],
);
const settlePendingMentionBindings = React.useCallback(async () => {
const pending = pendingRef.current;
if (pending.size === 0) return;
let timer: ReturnType<typeof setTimeout> | undefined;
const expiry = new Promise<"expired">((resolve) => {
timer = setTimeout(
() => resolve("expired"),
PENDING_MENTION_BINDING_TIMEOUT_MS,
);
});
try {
// A settlement can start another (a re-entrant paste cannot, but a
// second composer paste racing this drain can), so loop until empty
// rather than awaiting one snapshot of the set.
while (pending.size > 0) {
const outcome = await Promise.race([
// The settlements never reject — each one catch-logs internally.
Promise.all([...pending]).then(() => "drained" as const),
expiry,
]);
if (outcome === "expired") {
console.warn(
`Sending without ${pending.size} unsettled pasted mention identity check(s)`,
);
return;
}
}
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}, []);
return React.useMemo(
() => ({
bindPastedMentionIdentities,
claimMentionIntent,
clearMentionIntents,
settlePendingMentionBindings,
}),
[
bindPastedMentionIdentities,
claimMentionIntent,
clearMentionIntents,
settlePendingMentionBindings,
],
);
}

View File

@@ -0,0 +1,141 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getSchema } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { findMentionTokenSpans } from "./mentionTokenSpans.ts";
/**
* Mention tokens located in document coordinates.
*
* The fence on a pasted identity is the range its `@Label` occupies, so these
* pin the conversion both ways: a span has to cover the sigil and the whole
* label and nothing else, and it has to be a position the editor agrees with —
* every case reads the span back out of the document and compares text.
*/
const schema = getSchema([
StarterKit.configure({ heading: false, trailingNode: false, link: false }),
]);
const text = (value) => schema.text(value);
const paragraph = (...content) => schema.nodes.paragraph.create(null, content);
const document_ = (...content) => schema.nodes.doc.create(null, content);
const LABEL = "John Smith";
const TOKEN = `@${LABEL}`;
/** What the document actually holds at each returned span. */
function spanTexts(doc, spans) {
return spans.map((span) => doc.textBetween(span.from, span.to, "\n", "\n"));
}
function wholeDoc(doc) {
return { from: 0, to: doc.content.size };
}
test("a span covers the sigil and the whole label", () => {
const doc = document_(paragraph(text(`Hello ${TOKEN} fixed the bug`)));
const spans = findMentionTokenSpans(doc, wholeDoc(doc), [LABEL]);
assert.equal(spans.length, 1);
assert.deepEqual(spanTexts(doc, spans), [TOKEN]);
assert.equal(spans[0].label, LABEL);
});
test("spans come back in document order across labels", () => {
const doc = document_(paragraph(text(`@Fizz and ${TOKEN} and @Fizz again`)));
const spans = findMentionTokenSpans(doc, wholeDoc(doc), ["Fizz", LABEL]);
assert.deepEqual(spanTexts(doc, spans), ["@Fizz", TOKEN, "@Fizz"]);
});
test("positions survive a block boundary before the mention", () => {
// The second paragraph's characters sit past its own node position, so a
// span read off a flat offset would land a character or two to the left.
const doc = document_(
paragraph(text("first line")),
paragraph(text(`then ${TOKEN} replied`)),
);
const spans = findMentionTokenSpans(doc, wholeDoc(doc), [LABEL]);
assert.deepEqual(spanTexts(doc, spans), [TOKEN]);
});
test("a mention split across a block boundary yields no span", () => {
// The separator makes the run read as a mention of nothing contiguous;
// there is no single range to fence, so there is nothing to bind.
const doc = document_(paragraph(text("@John")), paragraph(text("Smith")));
assert.deepEqual(findMentionTokenSpans(doc, wholeDoc(doc), [LABEL]), []);
});
test("marks inside the label do not split its span", () => {
// TipTap parses `**@John** Smith` into two text nodes; the token is still
// one contiguous run of characters, and the fence has to hold all of it.
const bold = schema.marks.bold.create();
const doc = document_(
paragraph(text("@John", [bold]), text(" Smith fixed the bug")),
);
const spans = findMentionTokenSpans(doc, wholeDoc(doc), [LABEL]);
assert.deepEqual(spanTexts(doc, spans), [TOKEN]);
});
test("only mentions inside the range are returned", () => {
const doc = document_(paragraph(text(`${TOKEN} and ${TOKEN} again`)));
const second = 1 + `${TOKEN} and `.length;
const spans = findMentionTokenSpans(
doc,
{ from: second, to: doc.content.size },
[LABEL],
);
assert.equal(spans.length, 1);
assert.equal(spans[0].from, second);
assert.deepEqual(spanTexts(doc, spans), [TOKEN]);
});
test("a range that cuts the label short yields no span", () => {
// Fail closed: half a token is not the mention the clipboard named.
const doc = document_(paragraph(text(`${TOKEN} fixed the bug`)));
assert.deepEqual(
findMentionTokenSpans(doc, { from: 1, to: 1 + "@John Sm".length }, [LABEL]),
[],
);
});
test("text with no word boundary after the label yields no span", () => {
// The same rule every other mention layer applies: `@John Smithx` names
// nobody, so nothing there can carry an identity.
const doc = document_(paragraph(text(`${TOKEN}x fixed the bug`)));
assert.deepEqual(findMentionTokenSpans(doc, wholeDoc(doc), [LABEL]), []);
});
test("an empty or inverted range yields no spans", () => {
const doc = document_(paragraph(text(`${TOKEN} fixed the bug`)));
assert.deepEqual(findMentionTokenSpans(doc, { from: 5, to: 5 }, [LABEL]), []);
assert.deepEqual(findMentionTokenSpans(doc, { from: 9, to: 2 }, [LABEL]), []);
});
test("a range past the document is clamped rather than trusted", () => {
// Settlement asks for a character either side of a tracked range, which at
// the document's edge is a position that does not exist.
const doc = document_(paragraph(text(TOKEN)));
const spans = findMentionTokenSpans(
doc,
{ from: -5, to: doc.content.size + 5 },
[LABEL],
);
assert.deepEqual(spanTexts(doc, spans), [TOKEN]);
});

View File

@@ -0,0 +1,122 @@
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { getMentionOffsets } from "./hasMention";
/**
* Where the `@Label` runs of a document range actually sit.
*
* A pasted identity is fenced by the text it owns, and what it owns is the
* mention token — not the whole sentence the paste happened to carry. Holding
* the token means an edit elsewhere in the paste costs nothing, while any edit
* to the token itself revokes the identity riding on it.
*
* Spans are read out of the *document* rather than out of the clipboard's own
* text, because the document is what the fence has to hold: the Markdown
* branch inserts source that TipTap parses, so `**@John Smith**` on the
* clipboard is `@John Smith` in the doc. `getMentionOffsets` is the matcher
* every other mention layer uses, so a span exists exactly where a send would
* find a mention.
*/
/** One `@Label` run, in document coordinates. */
export type MentionTokenSpan = {
label: string;
from: number;
to: number;
};
/** Stands in for text a range carries that no single position holds. */
const NO_POSITION = -1;
type RangeText = {
text: string;
/** Document position of each character, or `NO_POSITION`. */
positions: number[];
};
/**
* The text of `[from, to)` beside the document position of each character.
*
* Mirrors `doc.textBetween(from, to, "\n", "\n")` so the matcher reads what
* the rest of the mention machinery reads, and records where every character
* came from so a match converts back into document coordinates. Block
* boundaries and non-text leaves contribute a newline owned by no position: a
* label cannot span one, and a run that did would own no contiguous range.
*/
function readRangeText(
doc: ProseMirrorNode,
from: number,
to: number,
): RangeText {
const chars: string[] = [];
const positions: number[] = [];
let firstBlock = true;
doc.nodesBetween(from, to, (node, pos) => {
if (node.isText) {
const value = node.text ?? "";
const start = Math.max(from, pos);
const end = Math.min(to, pos + value.length);
for (let at = start; at < end; at += 1) {
chars.push(value[at - pos]);
positions.push(at);
}
return false;
}
if (node.isTextblock) {
if (firstBlock) firstBlock = false;
else {
chars.push("\n");
positions.push(NO_POSITION);
}
return true;
}
if (node.isLeaf) {
chars.push("\n");
positions.push(NO_POSITION);
return false;
}
return true;
});
return { text: chars.join(""), positions };
}
/**
* Locate every `@label` token of `labels` inside `[range.from, range.to)`.
*
* Spans come back in document order, so a caller that has to bound how many it
* keeps drops the ones furthest into the range rather than starving a label
* that happens to sort late.
*/
export function findMentionTokenSpans(
doc: ProseMirrorNode,
range: { from: number; to: number },
labels: readonly string[],
): MentionTokenSpan[] {
const from = Math.max(0, Math.min(range.from, doc.content.size));
const to = Math.max(from, Math.min(range.to, doc.content.size));
if (to <= from) return [];
const { text, positions } = readRangeText(doc, from, to);
const spans: MentionTokenSpan[] = [];
const seen = new Set<string>();
for (const label of labels) {
// `getMentionOffsets` returns the offset of the sigil, and the match is
// case-insensitive rather than fuzzy, so the run is always `@` + label.
const length = label.length + 1;
for (const offset of getMentionOffsets(text, label)) {
const end = offset + length;
if (end > positions.length) continue;
const start = positions[offset];
const last = positions[end - 1];
// A run broken by a block boundary or a non-text node owns no single
// range; skip it rather than inventing one that spans the break.
if (start === NO_POSITION || last === NO_POSITION) continue;
if (last - start !== end - 1 - offset) continue;
const key = `${start} ${last}`;
if (seen.has(key)) continue;
seen.add(key);
spans.push({ label, from: start, to: last + 1 });
}
}
return spans.sort((a, b) => a.from - b.from);
}

View File

@@ -1,11 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";
import { after, before, test } from "node:test";
import { hasMentionClipboardHtml } from "./normalizeMentionClipboard.ts";
import { JSDOM } from "jsdom";
import { truncateInlineChipLabel } from "@/shared/ui/mentionChip";
import {
parseMentionClipboardRecords,
selectVisibleMentionIdentities,
} from "./mentionClipboard.ts";
import {
hasMentionClipboardHtml,
normalizeMentionClipboardContent,
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.
// This file tests the pure string-matching detection function.
// `normalizeMentionClipboardContent` needs a DOM; jsdom supplies the same
// globals it reaches for in the webview.
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
before(() => {
Object.assign(globalThis, {
DOMParser: dom.window.DOMParser,
Element: dom.window.Element,
HTMLElement: dom.window.HTMLElement,
Node: dom.window.Node,
});
});
after(() => dom.window.close());
// ── hasMentionClipboardHtml ───────────────────────────────────────────
@@ -42,3 +65,203 @@ test("returns false for text that mentions 'data-mention' as content", () => {
const html = "<p>The attribute is called data-mention</p>";
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("", "@"), "");
});
// ── normalizeMentionClipboardContent ──────────────────────────────────
/** Nobody's key — what a crafted clipboard sidecar would name instead. */
const IMPOSTOR_PUBKEY = "1f".repeat(32);
const JOHN_SMITH_PUBKEY = "7c".repeat(32);
/** The records the paste would actually register, given the inserted text. */
function visibleIdentities(html, text) {
return selectVisibleMentionIdentities(
parseMentionClipboardRecords(html),
text,
);
}
test("text ProseMirror never inserts cannot vouch for an identity record", () => {
// The reported vector: a real member's display name hidden in a <style>,
// beside an empty record span claiming it against an attacker's key. The
// composer shows only "visible", so the binding would be invisible to the
// user who accepted it — and it outlives the paste that carried it.
const html =
"visible <style>@Jane Doe </style>" +
`<span data-mention="" data-mention-pubkey="${IMPOSTOR_PUBKEY}" ` +
'data-mention-label="Jane Doe"></span>';
const content = normalizeMentionClipboardContent(html);
assert.equal(content.text.includes("Jane Doe"), false);
assert.equal(content.html.includes("Jane Doe"), false);
assert.deepEqual(visibleIdentities(html, content.text), []);
});
for (const tag of ["script", "style", "title", "noscript", "object"]) {
test(`<${tag}> text reaches neither output`, () => {
const content = normalizeMentionClipboardContent(
`<p>visible</p><${tag}>@Jane Doe </${tag}>`,
);
assert.equal(content.text.includes("Jane Doe"), false);
assert.equal(content.html.includes("Jane Doe"), false);
// The content the paste does insert survives the sweep.
assert.equal(content.text.includes("visible"), true);
});
}
test("a leading <style> the parser hoists into <head> is excluded too", () => {
// Documents the hoist: this text lands outside <body>, so it is absent from
// both body-derived outputs whether or not the sweep runs. The sweep spans
// the whole document rather than <body> so that stays true if the
// serialization root ever moves.
const content = normalizeMentionClipboardContent(
"<style>@Jane Doe </style>visible",
);
assert.equal(content.text.includes("Jane Doe"), false);
assert.equal(content.html.includes("Jane Doe"), false);
assert.equal(content.text.includes("visible"), true);
});
test("a mention span nested in an ignored tag contributes nothing", () => {
// A DOMParser document has scripting disabled, so <noscript> and <object>
// hold real element children — including a chip. Sweeping before the
// flattening loop removes it outright, rather than flattening it into
// sigiled text the gate would then read as visible.
const html =
"<p>visible</p><object>" +
`<span data-mention="" data-mention-pubkey="${IMPOSTOR_PUBKEY}" ` +
'data-mention-label="Jane Doe">Jane Doe</span></object>';
const content = normalizeMentionClipboardContent(html);
assert.equal(content.text.includes("Jane Doe"), false);
assert.equal(content.html.includes("Jane Doe"), false);
assert.deepEqual(visibleIdentities(html, content.text), []);
});
test("a chip rendered past the length cap keeps the label it declares", () => {
// A long channel name renders ellipsized while `data-channel-label` still
// declares it whole. Reading that as a partial selection would drop the `#`
// and paste dead text — the original bug, on any long channel reference.
const label = `release-${"x".repeat(80)}-notes`;
const rendered = truncateInlineChipLabel(label);
assert.notEqual(rendered, label, "fixture must exceed the chip cap");
const content = normalizeMentionClipboardContent(
`<p>see <span data-channel-link="" data-channel-label="${label}">` +
`${rendered}</span> for details</p>`,
);
assert.equal(content.text.includes(`#${label}`), true);
// A genuine fragment of that same chip still gains no sigil.
const fragment = normalizeMentionClipboardContent(
`<p><span data-channel-link="" data-channel-label="${label}">` +
`${rendered.slice(0, 10)}</span> for details</p>`,
);
assert.equal(fragment.text.includes("#"), false);
});
/**
* What a pasteboard round trip can swap a chip's spaces for.
*
* Built rather than written as an escape: a U+00A0 that decays into an
* invisible literal is unreadable in a diff and silently changes what these
* fixtures assert.
*/
const NBSP = String.fromCharCode(0x00a0);
test("a chip whose spaces became NBSP in transit still delivers its identity", () => {
// `matchChipTextToLabel` tolerates this mutation when classifying the chip as
// whole — so the text written back must not carry it. Every downstream
// matcher goes through `getMentionOffsets`, which wants the label's literal
// characters: an inserted "@John<NBSP>Smith" is sigiled dead text whose
// identity record the visibility gate then silently drops.
const html =
"<p>hey " +
`<span data-mention="" data-mention-pubkey="${JOHN_SMITH_PUBKEY}" ` +
`data-mention-label="John Smith">@John${NBSP}Smith</span> here</p>`;
const content = normalizeMentionClipboardContent(html);
assert.equal(content.text.includes("@John Smith"), true);
assert.equal(content.text.includes(NBSP), false);
assert.deepEqual(
visibleIdentities(html, content.text).map((record) => record.pubkey),
[JOHN_SMITH_PUBKEY],
);
});
test("a chip padded around its label pastes the label, not the padding", () => {
// The classifier's other transit tolerance, through the same door: written
// verbatim this inserts "@ John Smith ", which matches nothing.
const html =
"<p>hey " +
`<span data-mention="" data-mention-pubkey="${JOHN_SMITH_PUBKEY}" ` +
'data-mention-label="John Smith"> John Smith </span> here</p>';
const content = normalizeMentionClipboardContent(html);
assert.equal(content.text.includes("@John Smith"), true);
assert.equal(content.text.includes("@ John"), false);
assert.deepEqual(
visibleIdentities(html, content.text).map((record) => record.pubkey),
[JOHN_SMITH_PUBKEY],
);
});
test("a padded label attribute is trimmed to what the record carries", () => {
// Pins the trim in the write-back rather than the write-back itself: the
// record parser trims `data-mention-label`, so an untrimmed write-back would
// insert "@ John Smith" against a record reading "John Smith" — the same
// silent drop as above. Drop the `.trim()` and this fails.
const html =
"<p>hey " +
`<span data-mention="" data-mention-pubkey="${JOHN_SMITH_PUBKEY}" ` +
'data-mention-label=" John Smith ">John Smith</span> here</p>';
const content = normalizeMentionClipboardContent(html);
assert.equal(content.text.includes("@John Smith"), true);
assert.deepEqual(
visibleIdentities(html, content.text).map((record) => record.pubkey),
[JOHN_SMITH_PUBKEY],
);
});
test("a chip declaring no label keeps its text verbatim", () => {
// Legacy markup from before the label attributes: there is no declared label
// to write back and no record to deliver, so the text stands as copied.
const content = normalizeMentionClipboardContent(
`<p>hey <span data-mention="">John${NBSP}Smith</span> here</p>`,
);
assert.equal(content.text.includes(`@John${NBSP}Smith`), true);
});
test("an ordinary chip still flattens to a registrable mention", () => {
const html =
"<p>hey " +
`<span data-mention="" data-mention-pubkey="${JOHN_SMITH_PUBKEY}" ` +
'data-mention-label="John Smith">John Smith</span> here</p>';
const content = normalizeMentionClipboardContent(html);
// The sigil the chip strips for display is restored, and the identity
// attributes stay out of the markup handed to the composer.
assert.equal(content.text.includes("@John Smith"), true);
assert.equal(content.html.includes("data-mention-pubkey"), false);
assert.deepEqual(
visibleIdentities(html, content.text).map((record) => record.pubkey),
[JOHN_SMITH_PUBKEY],
);
});

View File

@@ -1,3 +1,9 @@
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).
@@ -6,27 +12,172 @@ 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 string that preserves surrounding formatting
* (bold, italic, line breaks, etc.) while stripping only the mention/
* channel-link styling.
* 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 normalizeMentionClipboardHtml(html: string): string {
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.
// 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");
span.textContent = el.textContent ?? "";
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);
}
@@ -46,5 +197,5 @@ export function normalizeMentionClipboardHtml(html: string): string {
}
}
return doc.body.innerHTML;
return { html: doc.body.innerHTML, text: readRenderedText(doc.body) };
}

View File

@@ -0,0 +1,268 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getSchema } from "@tiptap/core";
import { EditorState } from "@tiptap/pm/state";
import StarterKit from "@tiptap/starter-kit";
import {
PastedMentionOccurrencesExtension,
readPastedMentionOccurrenceRange,
releasePastedMentionOccurrence,
trackPastedMentionOccurrence,
} from "./pastedMentionOccurrences.ts";
/**
* Occurrence ownership, driven through the real ProseMirror plugin.
*
* A pasted mention's identity check can outlive the paste, so settlement has
* to ask whether the text *that paste* inserted is still there. These cases
* pin what "still" means: positions follow edits elsewhere, and the range dies
* rather than drifting onto text the user typed — including when the user
* edits strictly inside it, where both endpoints survive untouched.
*/
const schema = getSchema([
StarterKit.configure({ heading: false, trailingNode: false, link: false }),
]);
const paragraph = (...content) => schema.nodes.paragraph.create(null, content);
const text = (value) => schema.text(value);
const document = (...content) => schema.nodes.doc.create(null, content);
const PASTED = "@John Smith fixed the bug";
/**
* A stand-in for `EditorView` over a real `EditorState`.
*
* The plugin's `apply` — the whole mechanism under test — runs on every
* dispatch, without needing a DOM.
*/
function viewWith(initialText, { plugin = true } = {}) {
const plugins = plugin
? PastedMentionOccurrencesExtension.config.addProseMirrorPlugins.call({})
: [];
const view = {
state: EditorState.create({
doc: document(paragraph(text(initialText))),
schema,
plugins,
}),
dispatch(tr) {
view.state = view.state.apply(tr);
},
};
return view;
}
/** Track the whole first paragraph, as a paste into an empty composer does. */
function trackWholeParagraph(view) {
return trackPastedMentionOccurrence(view, 1, view.state.doc.content.size - 1);
}
/** The text an occurrence still owns, or `null` once its range is gone. */
function ownedText(view, id) {
const range = readPastedMentionOccurrenceRange(view, id);
if (!range) return null;
return view.state.doc.textBetween(range.from, range.to, "\n", "\n");
}
function replaceRange(view, from, to, replacement) {
const tr = view.state.tr;
if (replacement === "") tr.delete(from, to);
else tr.replaceWith(from, to, text(replacement));
view.dispatch(tr);
}
test("an occurrence still reads its own text after an edit elsewhere", () => {
const view = viewWith(PASTED);
const id = trackWholeParagraph(view);
// Something typed at the end of the document, past the pasted run.
view.dispatch(
view.state.tr.insertText(" thanks", view.state.doc.content.size - 1),
);
assert.equal(ownedText(view, id), PASTED);
});
test("an occurrence's positions follow an insertion before it", () => {
const view = viewWith(PASTED);
const id = trackWholeParagraph(view);
view.dispatch(view.state.tr.insertText("see: ", 1));
assert.equal(view.state.doc.textContent, `see: ${PASTED}`);
assert.equal(ownedText(view, id), PASTED);
});
test("text typed at either edge stays outside the occurrence", () => {
// The paste owns what it inserted and nothing the user appended to it —
// otherwise a hand-typed label would grow into the range and bind.
const view = viewWith(PASTED);
const id = trackWholeParagraph(view);
view.dispatch(view.state.tr.insertText("@Jane Doe ", 1));
const afterHead = view.state.doc.content.size - 1;
view.dispatch(view.state.tr.insertText(" @Fizz", afterHead));
assert.equal(ownedText(view, id), PASTED);
});
test("an occurrence dies when its text is deleted", () => {
const view = viewWith(PASTED);
const id = trackWholeParagraph(view);
replaceRange(view, 1, view.state.doc.content.size - 1, "");
assert.equal(ownedText(view, id), null);
});
test("an occurrence dies when the same text is pasted over it", () => {
// Select-all-and-replace leaves an identical document, so a range that only
// watched for collapse would survive and claim someone else's paste.
const view = viewWith(PASTED);
const id = trackWholeParagraph(view);
replaceRange(view, 1, view.state.doc.content.size - 1, PASTED);
assert.equal(view.state.doc.textContent, PASTED);
assert.equal(ownedText(view, id), null);
});
test("an occurrence dies when the composer is cleared on send", () => {
const view = viewWith(PASTED);
const id = trackWholeParagraph(view);
view.dispatch(view.state.tr.delete(0, view.state.doc.content.size));
assert.equal(ownedText(view, id), null);
});
test("an occurrence dies when an edit eats into its head or tail", () => {
const head = viewWith(PASTED);
const headId = trackWholeParagraph(head);
replaceRange(head, 1, 2, "");
assert.equal(ownedText(head, headId), null);
const tail = viewWith(PASTED);
const tailId = trackWholeParagraph(tail);
const end = tail.state.doc.content.size - 1;
replaceRange(tail, end - 3, end, "");
assert.equal(ownedText(tail, tailId), null);
});
test("an occurrence dies when an edit replaces text strictly inside it", () => {
// Neither endpoint moves, and the document ends up character-for-character
// as it was — so endpoint mapping alone left the range alive and owning
// words the user had just typed out by hand.
const view = viewWith(PASTED);
const id = trackWholeParagraph(view);
const at = 1 + PASTED.indexOf("fixed");
replaceRange(view, at, at + "fixed".length, "fixed");
assert.equal(view.state.doc.textContent, PASTED);
assert.equal(ownedText(view, id), null);
});
test("an occurrence dies when a later step of one transaction overlaps it", () => {
// The overlap is only visible per step: the first step shifts the range, so
// a check against the transaction's combined mapping would compare the
// second step's replaced region to stale coordinates.
const view = viewWith(PASTED);
const id = trackWholeParagraph(view);
const tr = view.state.tr;
tr.insertText("see: ", 1);
const at = tr.mapping.map(1 + PASTED.indexOf("fixed"));
tr.replaceWith(at, at + "fixed".length, text("broke"));
view.dispatch(tr);
assert.equal(
view.state.doc.textContent,
`see: ${PASTED}`.replace("fixed", "broke"),
);
assert.equal(ownedText(view, id), null);
});
test("a replacement that only butts an occurrence's boundary spares it", () => {
// The replaced text was outside the range on either side, so it was never
// the paste's to lose. Killing here would cost a legitimate identity every
// time a word beside the paste is edited during verification.
const head = viewWith(`see: ${PASTED}`);
const headId = trackPastedMentionOccurrence(
head,
1 + "see: ".length,
head.state.doc.content.size - 1,
);
replaceRange(head, 1, 1 + "see: ".length, "");
assert.equal(ownedText(head, headId), PASTED);
const tail = viewWith(`${PASTED} thanks`);
const tailId = trackPastedMentionOccurrence(tail, 1, 1 + PASTED.length);
replaceRange(
tail,
1 + PASTED.length,
tail.state.doc.content.size - 1,
" cheers",
);
assert.equal(ownedText(tail, tailId), PASTED);
});
test("a pure insertion inside an occurrence leaves it holding the new text", () => {
// An insertion replaces nothing, so the range legitimately survives — the
// caller's own check on the surviving text is what refuses a token the user
// has typed into.
const view = viewWith(PASTED);
const id = trackWholeParagraph(view);
view.dispatch(view.state.tr.insertText("X", 1 + "@John".length));
assert.equal(ownedText(view, id), "@JohnX Smith fixed the bug");
});
test("releasing an occurrence retires it", () => {
const view = viewWith(PASTED);
const id = trackWholeParagraph(view);
releasePastedMentionOccurrence(view, id);
assert.equal(ownedText(view, id), null);
// Idempotent: a second release dispatches nothing and does not throw.
releasePastedMentionOccurrence(view, id);
});
test("occurrences are independent", () => {
const view = viewWith(`${PASTED} and @Fizz agrees`);
const first = trackPastedMentionOccurrence(view, 1, 1 + PASTED.length);
const second = trackPastedMentionOccurrence(
view,
1 + PASTED.length,
view.state.doc.content.size - 1,
);
replaceRange(view, 1, 1 + PASTED.length, "");
assert.equal(ownedText(view, first), null);
assert.equal(ownedText(view, second), " and @Fizz agrees");
});
test("tracks nothing without a plugin, or for an empty insertion", () => {
// Fail closed: a composer with no occurrence plugin, or a paste that
// inserted nothing, has no range a settlement could be held to.
const unregistered = viewWith(PASTED, { plugin: false });
assert.equal(trackWholeParagraph(unregistered), null);
const view = viewWith(PASTED);
assert.equal(trackPastedMentionOccurrence(view, 3, 3), null);
assert.equal(ownedText(view, null), null);
});
test("tracks nothing for a destroyed view", () => {
const view = viewWith(PASTED);
const id = trackWholeParagraph(view);
view.isDestroyed = true;
assert.equal(ownedText(view, id), null);
assert.equal(trackWholeParagraph(view), null);
});

View File

@@ -0,0 +1,214 @@
import { Extension } from "@tiptap/core";
import {
Plugin,
PluginKey,
type EditorState,
type Transaction,
} from "@tiptap/pm/state";
/**
* Ownership of the text one paste inserted, tracked across later edits.
*
* A pasted mention's identity can only be bound once trusted state vouches for
* it, and that check can need a relay round trip — so the answer lands well
* after the insertion. "Is this label visible in the composer?" is not a fence
* for that: the label the user deleted and then hand-typed reads the same as
* the one this paste put there, and binding the former hands a stranger's
* pubkey to text the composer's own candidates should have resolved.
*
* The plugin holds a range per in-flight claim and maps it through every
* transaction, so settlement can ask the narrower question: does the label
* still occupy the text *this* paste owns? A range dies when its content is
* deleted or replaced, which is the fail-closed direction — a settlement with
* no live range binds nothing.
*
* The ranges callers track are the individual `@Label` tokens rather than the
* whole insertion, so an edit elsewhere in a pasted sentence costs nothing
* while any edit to the token itself revokes the identity it carried. The
* plugin stays granularity-agnostic: it tracks whatever range it is handed.
*/
export const pastedMentionOccurrencesKey = new PluginKey<PastedMentionRanges>(
"pastedMentionOccurrences",
);
/** A half-open document range, in the coordinates the editor state uses. */
export type PastedMentionRange = { from: number; to: number };
type PastedMentionRanges = ReadonlyMap<number, PastedMentionRange>;
type PastedMentionOccurrenceCommand =
| { type: "track"; id: number; from: number; to: number }
| { type: "release"; id: number };
/**
* The slice of `EditorView` these helpers use.
*
* `EditorView` satisfies it structurally, so production passes the real view
* while a test can drive the real plugin over an `EditorState` without a DOM.
*/
export type PastedMentionOccurrenceView = {
state: EditorState;
dispatch: (tr: Transaction) => void;
isDestroyed?: boolean;
};
/**
* Every paste releases its ranges at settlement, so this cap is a backstop
* rather than a working limit: a composer whose settlements somehow stop
* arriving must not accumulate ranges for the life of the session. Callers
* also read it as the ceiling on what one paste may track — trimming costs a
* pasted identity rather than binding one, which is the safe direction.
*/
export const MAX_TRACKED_OCCURRENCES = 50;
let nextOccurrenceId = 1;
/**
* Map one tracked range through a transaction, or `null` once it is dead.
*
* Two rules, and endpoint mapping alone is neither of them. `from` maps with
* assoc 1 and `to` with assoc −1, so text typed at either edge lands *outside*
* the range: an occurrence owns what the paste inserted and nothing the user
* added around it. But both endpoints also survive an edit wholly *inside* the
* range, so endpoint mapping on its own left a range alive while the user
* replaced the very characters it was tracking.
*
* Each step is therefore checked for a replaced region that strictly overlaps
* the range first, and any overlap kills it — the range no longer owns what it
* was handed. Touching a boundary is not an overlap, since that text was
* always outside; a pure insertion replaces nothing at all, and the caller's
* own check on the surviving text is what refuses those.
*/
function remapPastedMentionRange(
range: PastedMentionRange,
tr: Transaction,
): PastedMentionRange | null {
let { from, to } = range;
for (const map of tr.mapping.maps) {
let overlapped = false;
map.forEach((oldStart, oldEnd) => {
if (oldEnd > oldStart && oldStart < to && oldEnd > from)
overlapped = true;
});
if (overlapped) return null;
const mappedFrom = map.mapResult(from, 1);
const mappedTo = map.mapResult(to, -1);
if (mappedFrom.deleted || mappedTo.deleted) return null;
from = mappedFrom.pos;
to = mappedTo.pos;
if (to <= from) return null;
}
return { from, to };
}
function remapPastedMentionRanges(
current: PastedMentionRanges,
tr: Transaction,
): PastedMentionRanges {
const next = new Map<number, PastedMentionRange>();
for (const [id, range] of current) {
const mapped = remapPastedMentionRange(range, tr);
if (mapped) next.set(id, mapped);
}
return next;
}
function applyPastedMentionCommand(
current: PastedMentionRanges,
command: PastedMentionOccurrenceCommand,
): PastedMentionRanges {
const next = new Map(current);
if (command.type === "release") {
next.delete(command.id);
return next;
}
next.set(command.id, { from: command.from, to: command.to });
for (const id of next.keys()) {
if (next.size <= MAX_TRACKED_OCCURRENCES) break;
next.delete(id);
}
return next;
}
/**
* Tracks the document range each in-flight pasted mention token occupies.
*
* Registered in the shared composer extension list, so every composer that
* accepts a mention paste — channel, DM, thread, edit, forum — can fence a
* late identity binding to the text its paste still owns.
*/
export const PastedMentionOccurrencesExtension = Extension.create({
name: "pastedMentionOccurrences",
addProseMirrorPlugins() {
return [
new Plugin<PastedMentionRanges>({
key: pastedMentionOccurrencesKey,
state: {
init: () => new Map(),
apply(tr, current) {
const mapped = tr.docChanged
? remapPastedMentionRanges(current, tr)
: current;
const command = tr.getMeta(pastedMentionOccurrencesKey) as
| PastedMentionOccurrenceCommand
| undefined;
return command
? applyPastedMentionCommand(mapped, command)
: mapped;
},
},
}),
];
},
});
/**
* Start tracking `[from, to)` as one mention token a paste inserted.
*
* Returns `null` when there is nothing to own (an empty insertion) or when the
* composer carries no occurrence plugin — callers treat that as "no live
* occurrence" and bind nothing, so an unregistered extension costs a pasted
* identity rather than binding one no fence can retire.
*/
export function trackPastedMentionOccurrence(
view: PastedMentionOccurrenceView,
from: number,
to: number,
): number | null {
if (view.isDestroyed || to <= from) return null;
if (!pastedMentionOccurrencesKey.getState(view.state)) return null;
const id = nextOccurrenceId++;
view.dispatch(
view.state.tr.setMeta(pastedMentionOccurrencesKey, {
type: "track",
id,
from,
to,
}),
);
return id;
}
/** Where an occurrence now sits, or `null` once its range is gone. */
export function readPastedMentionOccurrenceRange(
view: PastedMentionOccurrenceView,
id: number | null,
): PastedMentionRange | null {
if (id === null || view.isDestroyed) return null;
const range = pastedMentionOccurrencesKey.getState(view.state)?.get(id);
if (!range) return null;
return range.to > view.state.doc.content.size ? null : range;
}
/** Stop tracking an occurrence whose paste has finished settling. */
export function releasePastedMentionOccurrence(
view: PastedMentionOccurrenceView,
id: number | null,
): void {
if (id === null || view.isDestroyed) return;
if (!pastedMentionOccurrencesKey.getState(view.state)?.has(id)) return;
view.dispatch(
view.state.tr.setMeta(pastedMentionOccurrencesKey, { type: "release", id }),
);
}

View File

@@ -0,0 +1,149 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { JSDOM } from "jsdom";
import { truncateInlineChipLabel } from "@/shared/ui/mentionChip";
import {
buildTimelineClipboardFlavors,
handleTimelineMentionCopy,
} from "./timelineMentionCopy.ts";
// The copy handler works off a live selection and a rendered clone, so it needs
// real Selection/Range/getComputedStyle. jsdom has no layout, so `innerText` is
// undefined there and the plain flavor falls back to `selection.toString()` —
// the HTML flavor, which is what carries identity, is unaffected.
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
before(() => {
Object.assign(globalThis, {
document: dom.window.document,
getComputedStyle: dom.window.getComputedStyle.bind(dom.window),
HTMLElement: dom.window.HTMLElement,
window: dom.window,
});
});
after(() => dom.window.close());
const JOHN_SMITH_PUBKEY = "7c".repeat(32);
/** Render `bodyHtml`, select the whole thing, and run `fn` in that state. */
function withRenderedSelection(bodyHtml, fn) {
const body = dom.window.document.createElement("div");
body.className = "message-markdown";
body.innerHTML = bodyHtml;
dom.window.document.body.append(body);
const selection = dom.window.getSelection();
selection.removeAllRanges();
const range = dom.window.document.createRange();
range.selectNodeContents(body);
selection.addRange(range);
try {
return fn(selection);
} finally {
selection.removeAllRanges();
body.remove();
}
}
/** Render `bodyHtml` into the document and copy the whole thing. */
function copyRenderedBody(bodyHtml) {
return withRenderedSelection(bodyHtml, buildTimelineClipboardFlavors);
}
test("a whole mention chip copies with its sigil and identity", () => {
const flavors = copyRenderedBody(
`hey <span data-mention="" data-mention-pubkey="${JOHN_SMITH_PUBKEY}" ` +
'data-mention-label="John Smith" class="mention-chip">John Smith' +
"</span> fixed it",
);
assert.notEqual(flavors, null);
assert.equal(flavors.html.includes("@John Smith"), true);
assert.equal(
flavors.html.includes(`data-mention-pubkey="${JOHN_SMITH_PUBKEY}"`),
true,
);
});
test("a chip rendered past the length cap still copies its identity", () => {
// A long channel name renders ellipsized while `data-channel-label` declares
// it whole, so a *fully* selected chip's text is not its label. Classifying
// that as a partial selection strips the identity, leaves `restored` false,
// and drops the entire copy to the browser's default — dead, sigil-less text
// with nothing to signal the failure.
const label = `release-${"x".repeat(80)}-notes`;
const rendered = truncateInlineChipLabel(label);
assert.notEqual(rendered, label, "fixture must exceed the chip cap");
const flavors = copyRenderedBody(
`see <span data-channel-link="" data-channel-label="${label}" ` +
`class="mention-chip">${rendered}</span> for details`,
);
assert.notEqual(flavors, null, "copy must not fall through to the default");
assert.equal(flavors.html.includes(`#${label}`), true);
});
test("a partially selected chip loses its identity and gains no sigil", () => {
// The guard this shares with the paste side: a fragment must never become a
// mention the user did not copy.
const flavors = copyRenderedBody(
`<span data-mention="" data-mention-pubkey="${JOHN_SMITH_PUBKEY}" ` +
'data-mention-label="John Smith" class="mention-chip">John</span>',
);
assert.equal(flavors, null);
});
const WHOLE_CHIP_BODY =
`hey <span data-mention="" data-mention-pubkey="${JOHN_SMITH_PUBKEY}" ` +
'data-mention-label="John Smith" class="mention-chip">John Smith' +
"</span> fixed it";
test("the copy handler claims the event and writes both flavors", () => {
withRenderedSelection(WHOLE_CHIP_BODY, () => {
const written = new Map();
let prevented = false;
handleTimelineMentionCopy({
defaultPrevented: false,
preventDefault: () => {
prevented = true;
},
clipboardData: {
setData: (type, value) => written.set(type, value),
},
});
assert.equal(prevented, true);
assert.equal(written.get("text/html").includes("@John Smith"), true);
// jsdom has no layout, so the plain flavor is the selection's own text —
// sigil-less, but present.
assert.equal(written.get("text/plain").includes("John Smith"), true);
});
});
test("a copy whose clipboard data the browser withheld stays a no-op", () => {
// preventDefault() ahead of the clipboardData guard would suppress the
// default copy and then throw on setData — an empty clipboard. The handler
// must decline before touching the event.
withRenderedSelection(WHOLE_CHIP_BODY, () => {
let prevented = false;
handleTimelineMentionCopy({
defaultPrevented: false,
preventDefault: () => {
prevented = true;
},
clipboardData: null,
});
assert.equal(prevented, false);
});
});

View File

@@ -0,0 +1,169 @@
import type * as React from "react";
import { MESSAGE_MARKDOWN_CLASS } from "@/shared/ui/mentionChip";
import {
BUZZ_COPY_ATTRIBUTE,
CHANNEL_LABEL_ATTRIBUTE,
matchChipTextToLabel,
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 no longer carries 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.
*
* The classification is `matchChipTextToLabel`, the same predicate the paste
* side applies, rather than an equality test against the chip's rendered text:
* a chip that renders anything but its bare label — an ellipsized long label
* today — would otherwise read as a fragment, and a selection whose only chip
* did that would lose its identity and fall back to the browser's dead-text
* default with nothing to signal it.
*/
function restoreChipSigils(root: HTMLElement): boolean {
let restored = false;
for (const element of root.querySelectorAll<HTMLElement>("[data-mention]")) {
const label = element.getAttribute(MENTION_LABEL_ATTRIBUTE);
if (
label &&
matchChipTextToLabel(element.textContent ?? "", label, "@") !== "fragment"
) {
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<HTMLElement>(
"[data-channel-link]",
)) {
const label = element.getAttribute(CHANNEL_LABEL_ATTRIBUTE);
if (
label &&
matchChipTextToLabel(element.textContent ?? "", label, "#") !== "fragment"
) {
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<HTMLElement>(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 = `<span ${BUZZ_COPY_ATTRIBUTE}="rich">${clone.innerHTML}</span>`;
// `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;
// React's types claim clipboardData is always present, but the runtime can
// withhold it — guard before preventDefault(), or the default copy is
// suppressed and setData throws, leaving the clipboard empty.
const clipboardData: DataTransfer | null = event.clipboardData;
if (!clipboardData) return;
const flavors = buildTimelineClipboardFlavors(window.getSelection());
if (!flavors) return;
event.preventDefault();
clipboardData.setData("text/plain", flavors.text);
clipboardData.setData("text/html", flavors.html);
}

View File

@@ -39,6 +39,12 @@ import { useDefaultAgentSuggestion } from "./useDefaultAgentSuggestion";
import { flushMentionDebounce, isPlainSpace } from "./flushMentionDebounce";
import { useAgentMentionRevalidation } from "./agentMentionRevalidation";
import { extractMentionPubkeys } from "./extractMentionPubkeys";
import type { MentionIdentity } from "./mentionClipboard";
import {
useMentionPasteBinding,
type RegisterMentionPubkey,
} from "./mentionPasteBinding";
import { useVerifyMentionIdentities } from "./useVerifyMentionIdentities";
import {
extractMentionPersonasFromMaps,
type PersonaMentionTarget,
@@ -443,6 +449,40 @@ export function useMentions(
const { mentionSelectedIndex, setMentionSelectedIndex: setSelected } =
mentionSelection;
const isMentionOpen = mentionQuery !== null && suggestions.length > 0;
// Untrusted clipboard records only become bindable identities once trusted
// Buzz state confirms the pair — see `mentionIdentityTrust`.
const verifyMentionIdentities = useVerifyMentionIdentities({
mentionCandidates,
profiles,
});
// The map write a settled paste uses: it records a decision the user made
// earlier, so it must not outrank intent expressed since.
const writeMentionPubkey = React.useCallback<RegisterMentionPubkey>(
(displayName, pubkey, options) => {
const trimmedName = displayName.trim();
if (!trimmedName) {
return;
}
mentionMapRef.current.set(trimmedName, pubkey);
personaMentionMapRef.current.delete(trimmedName);
trimMapToSize(mentionMapRef.current, 200);
setSelectedMentionNames((current) =>
appendUniqueName(current, trimmedName),
);
if (options?.isAgent) {
selectedAgentMentionNamesRef.current = appendUniqueName(
selectedAgentMentionNamesRef.current,
trimmedName,
);
setSelectedAgentMentionNames(selectedAgentMentionNamesRef.current);
}
},
[],
);
const pasteBinding = useMentionPasteBinding({
registerVerifiedMentionPubkey: writeMentionPubkey,
verifyMentionIdentities,
});
const insertMention = React.useCallback(
(suggestion: MentionSuggestion, selectionEnd: number): AutocompleteEdit => {
if (debounceTimerRef.current !== null) {
@@ -459,6 +499,10 @@ export function useMentions(
const personaMentions = personaMentionMapRef.current;
const selectedMentions = teamMembers ?? [suggestion];
for (const selected of selectedMentions) {
// A picked name is the user's newest word on that label, so it retires
// any pasted identity still being verified for it — persona routing
// included, since a settled paste would delete that entry.
pasteBinding.claimMentionIntent(selected.displayName);
if (selected.kind === "persona" && selected.personaId) {
personaMentions.set(selected.displayName, selected.personaId);
mentions.delete(selected.displayName);
@@ -515,30 +559,55 @@ export function useMentions(
insertText,
};
},
[knownAgentPubkeys, mentionStartIndex, setSelected],
[
knownAgentPubkeys,
mentionStartIndex,
pasteBinding.claimMentionIntent,
setSelected,
],
);
const registerMentionPubkey = React.useCallback(
(displayName: string, pubkey: string, options?: { isAgent?: boolean }) => {
const trimmedName = displayName.trim();
if (!trimmedName) {
return;
}
mentionMapRef.current.set(trimmedName, pubkey);
personaMentionMapRef.current.delete(trimmedName);
trimMapToSize(mentionMapRef.current, 200);
setSelectedMentionNames((current) =>
appendUniqueName(current, trimmedName),
);
if (options?.isAgent) {
selectedAgentMentionNamesRef.current = appendUniqueName(
selectedAgentMentionNamesRef.current,
trimmedName,
);
setSelectedAgentMentionNames(selectedAgentMentionNamesRef.current);
}
// Every caller is explicit user intent — a resolved insert, an agent-address
// lock, a persona created at send time — so this claims the label before
// writing it, and a paste still verifying that name settles into nothing.
const registerMentionPubkey = React.useCallback<RegisterMentionPubkey>(
(displayName, pubkey, options) => {
pasteBinding.claimMentionIntent(displayName);
writeMentionPubkey(displayName, pubkey, options);
},
[],
[pasteBinding.claimMentionIntent, writeMentionPubkey],
);
const getMentionIdentities = React.useCallback((): MentionIdentity[] => {
const agentNames = new Set(
selectedAgentMentionNamesRef.current.map((name) =>
name.trim().toLowerCase(),
),
);
const identities: MentionIdentity[] = [];
const claimed = new Set<string>();
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,
@@ -715,7 +784,10 @@ export function useMentions(
selectedAgentMentionPubkeysRef.current.clear();
setSelectedMentionNames([]);
setSelectedAgentMentionNames([]);
}, [cancelMentionAutocomplete]);
// Belt to the occurrence fence's braces: a paste still verifying when the
// composer is cleared holds a claim nothing can match afterwards.
pasteBinding.clearMentionIntents();
}, [cancelMentionAutocomplete, pasteBinding.clearMentionIntents]);
const { getDraftMentionRefs, restoreDraftMentionRefs } =
useDraftMentionRouting({
mentionMapRef,
@@ -817,6 +889,7 @@ export function useMentions(
],
);
return {
bindPastedMentionIdentities: pasteBinding.bindPastedMentionIdentities,
cancelMentionAutocomplete,
clearMentions,
getDefaultAgentSuggestion,
@@ -824,6 +897,7 @@ export function useMentions(
extractMentionPubkeys: extractMentionPubkeysForCurrentMentions,
revalidateMentionPubkeys,
getDraftMentionRefs,
getMentionIdentities,
getMentionDisplayName,
handleMentionKeyDown,
hasResolvedMembers: members !== undefined,
@@ -841,6 +915,7 @@ export function useMentions(
openMentionPicker,
registerMentionPubkey,
restoreDraftMentionRefs,
settlePendingMentionBindings: pasteBinding.settlePendingMentionBindings,
suggestions,
fetchMoreSuggestions,
hasMoreSuggestions: Boolean(userSearchQuery.hasNextPage),

View File

@@ -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]);
}

View File

@@ -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,13 @@ import {
settleAutocompleteMentionInsert,
syncMentionHighlightFromProps,
} from "./mentionHighlightExtension";
import { handleComposerMentionCopy } from "./composerMentionCopy";
import { PastedMentionOccurrencesExtension } from "./pastedMentionOccurrences";
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 +51,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 +84,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 +151,7 @@ export function useRichTextEditor({
channelNames,
messageLinkChannels,
customEmoji,
getMentionIdentities,
onSubmit,
onEditLastOwnMessage,
isAutocompleteOpen,
@@ -179,6 +174,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 +215,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<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;
},
};
},
}),
MacEmacsTextShortcuts,
// Shift+Enter inside lists/blockquotes: split the node instead of
// inserting a hard break so continuation lines keep their formatting.
Extension.create({
@@ -409,6 +330,9 @@ export function useRichTextEditor({
CodeBlockAfterHardBreak,
SpoilerMark,
MentionHighlightExtension,
// Lets a pasted mention's identity check, which can outlive the paste,
// tell the text it inserted from whatever the user typed next.
PastedMentionOccurrencesExtension,
customEmojiWiring.extension,
messageLinkWiring.extension,
Placeholder.configure({
@@ -450,6 +374,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") ??

View File

@@ -0,0 +1,276 @@
import assert from "node:assert/strict";
import { after, afterEach, before, test } from "node:test";
import { JSDOM } from "jsdom";
/**
* The paste-side trust gate, driven through the hook the composers pass to
* `handleMentionClipboardPaste`.
*
* Clipboard HTML is attacker-authored, so a `label → pubkey` pair being
* *visible* in a paste proves only that the user saw a name. These cases pin
* the other half: the pair has to be one this community's own state names.
*/
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
/** 64-hex, the only shape `parseMentionClipboardRecords` lets through. */
const JOHN = "a".repeat(64);
const IMPOSTOR = "b".repeat(64);
const FIZZ = "c".repeat(64);
const johnSmith = { label: "John Smith", pubkey: JOHN, isAgent: false };
/** Commands the hook sent to the backend during one case. */
let invocations = [];
/** `get_users_batch` responses, keyed by lowercase pubkey. */
let relayProfiles = new Map();
/** Every client a case rendered, so its cache timers die with the case. */
const queryClients = [];
before(() => {
dom.window.__TAURI_INTERNALS__ = {
invoke: async (command, args) => {
invocations.push({ command, args });
if (command !== "get_users_batch") {
throw new Error(`Unexpected command in this test: ${command}`);
}
const profiles = {};
const missing = [];
for (const pubkey of args.pubkeys) {
const profile = relayProfiles.get(pubkey);
if (profile) profiles[pubkey] = profile;
else missing.push(pubkey);
}
return { profiles, missing };
},
};
Object.assign(globalThis, {
document: dom.window.document,
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
localStorage: dom.window.localStorage,
window: dom.window,
});
});
afterEach(async () => {
invocations = [];
relayProfiles = new Map();
const { cleanup } = await import("@testing-library/react");
cleanup();
// Cached entries schedule garbage-collection timers; left running they hold
// the test runner's event loop open long after the assertions pass.
for (const client of queryClients.splice(0)) client.clear();
});
after(() => dom.window.close());
/**
* Render the hook with the trusted state a composer would hand it.
*
* The QueryClient is per-case so a cached profile entry from one case cannot
* silently vouch for the next.
*/
async function renderVerifier({ mentionCandidates = [], profiles } = {}) {
const React = await import("react");
const { renderHook } = await import("@testing-library/react");
const { QueryClient, QueryClientProvider } = await import(
"@tanstack/react-query"
);
const { useVerifyMentionIdentities } = await import(
"./useVerifyMentionIdentities.ts"
);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClients.push(queryClient);
const { result } = renderHook(
() => useVerifyMentionIdentities({ mentionCandidates, profiles }),
{
wrapper: ({ children }) =>
React.createElement(
QueryClientProvider,
{ client: queryClient },
children,
),
},
);
return { queryClient, verify: result.current };
}
function relayCalls() {
return invocations.filter((entry) => entry.command === "get_users_batch");
}
test("vouches for a pair the surface's own profile lookup names", async () => {
const { verify } = await renderVerifier({
profiles: { [JOHN]: { displayName: "John Smith" } },
});
assert.deepEqual(await verify([johnSmith]), [johnSmith]);
assert.deepEqual(relayCalls(), [], "local state must not cost a round trip");
});
test("vouches for any alias the renderer could have chipped", async () => {
// `collectProfileAliases` is what turned a `p` tag into the label a copy
// carries; the check has to accept the same set or a legitimate copy of a
// kind-0-`name` or NIP-05 chip fails closed.
const { verify } = await renderVerifier({
profiles: {
[JOHN]: {
displayName: "J. Smith",
name: "John Smith",
nip05Handle: "jsmith@example.com",
},
},
});
assert.deepEqual(await verify([johnSmith]), [johnSmith]);
assert.deepEqual(
await verify([{ label: "jsmith", pubkey: JOHN, isAgent: false }]),
[{ label: "jsmith", pubkey: JOHN, isAgent: false }],
);
});
test("vouches for the persona name a managed agent's chip renders", async () => {
// An agent renders under its persona, which is no alias of its kind-0
// profile — the directory that named it has to be able to answer.
const fizz = { label: "Fizz", pubkey: FIZZ, isAgent: true };
const { verify } = await renderVerifier({
mentionCandidates: [
{
kind: "identity",
pubkey: FIZZ.toUpperCase(),
displayName: "Fizz",
isMember: true,
isAgent: true,
},
],
});
assert.deepEqual(await verify([fizz]), [fizz]);
assert.deepEqual(relayCalls(), []);
});
test("asks the relay about a pubkey no local state has seen", async () => {
// The headline case: a mention of someone who is not a member of the
// channel being pasted into, so nothing local can speak for the pair.
relayProfiles.set(JOHN, {
pubkey: JOHN,
display_name: "John Smith",
is_agent: false,
});
const { verify } = await renderVerifier();
assert.deepEqual(await verify([johnSmith]), [johnSmith]);
assert.deepEqual(relayCalls().length, 1);
});
test("drops a visible pair this community has never seen", async () => {
// The hostile shape: `<span data-mention-pubkey="<their key>"
// data-mention-label="John Smith">@John Smith</span>`. It pastes plausibly
// and the user sees it — and no trusted state names that key "John Smith".
const forged = { label: "John Smith", pubkey: IMPOSTOR, isAgent: false };
const { verify } = await renderVerifier({
profiles: { [JOHN]: { displayName: "John Smith" } },
});
assert.deepEqual(await verify([forged]), []);
});
test("drops a pair whose key the relay knows under another name", async () => {
// Same forgery against a key that does exist here. Knowing the key is not
// the question; the pair being one this community holds is.
relayProfiles.set(IMPOSTOR, {
pubkey: IMPOSTOR,
display_name: "Mallory",
is_agent: false,
});
const { verify } = await renderVerifier();
assert.deepEqual(
await verify([{ label: "John Smith", pubkey: IMPOSTOR, isAgent: false }]),
[],
);
});
test("separates the vouched-for from the forged in one paste", async () => {
relayProfiles.set(JOHN, {
pubkey: JOHN,
display_name: "John Smith",
is_agent: false,
});
const forged = { label: "Fizz", pubkey: IMPOSTOR, isAgent: true };
const { verify } = await renderVerifier();
assert.deepEqual(await verify([johnSmith, forged]), [johnSmith]);
// Both unknown pubkeys resolved in one request.
assert.deepEqual(relayCalls().length, 1);
assert.deepEqual(
relayCalls()[0].args.pubkeys.sort(),
[JOHN, IMPOSTOR].sort(),
);
});
test("answers from a fresh users-batch entry without a round trip", async () => {
const { queryClient, verify } = await renderVerifier();
const { usersBatchEntryKey } = await import("@/features/profile/hooks.ts");
queryClient.setQueryData(usersBatchEntryKey(JOHN), {
summary: { displayName: "John Smith" },
fetchedAt: Date.now(),
});
assert.deepEqual(await verify([johnSmith]), [johnSmith]);
assert.deepEqual(relayCalls(), []);
});
test("refetches rather than trusting an entry the profile hooks call stale", async () => {
relayProfiles.set(JOHN, {
pubkey: JOHN,
display_name: "John Smith",
is_agent: false,
});
const { queryClient, verify } = await renderVerifier();
const { usersBatchEntryKey, USERS_BATCH_ENTRY_FRESH_MS } = await import(
"@/features/profile/hooks.ts"
);
// A stale entry recording a relay-confirmed miss: believing it would drop a
// legitimate identity whose profile has since arrived.
queryClient.setQueryData(usersBatchEntryKey(JOHN), {
summary: null,
fetchedAt: Date.now() - USERS_BATCH_ENTRY_FRESH_MS - 1,
});
assert.deepEqual(await verify([johnSmith]), [johnSmith]);
assert.deepEqual(relayCalls().length, 1);
});
test("tolerates what a pasteboard leaves on a declared label", async () => {
// A round trip through another app can swap spaces for U+00A0 and pad the
// attribute; the pair is still the one the community holds.
const roundTripped = {
label: ` John${String.fromCharCode(0xa0)}smith `,
pubkey: JOHN,
isAgent: false,
};
const { verify } = await renderVerifier({
profiles: { [JOHN]: { displayName: "John Smith" } },
});
assert.deepEqual(await verify([roundTripped]), [roundTripped]);
});
test("drops a pair with no label at all", async () => {
const { verify } = await renderVerifier({
profiles: { [JOHN]: { displayName: "" } },
});
assert.deepEqual(
await verify([{ label: "", pubkey: JOHN, isAgent: false }]),
[],
);
});

View File

@@ -0,0 +1,131 @@
import * as React from "react";
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import {
USERS_BATCH_ENTRY_FRESH_MS,
usersBatchEntryKey,
type UsersBatchEntry,
} from "@/features/profile/hooks";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { getUsersBatch } from "@/shared/api/tauriProfiles";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { collectProfileAliases } from "@/shared/lib/resolveMentionNames";
import type { MentionCandidate } from "./mentionCandidates";
import type {
MentionIdentity,
VerifyMentionIdentities,
} from "./mentionClipboard";
import { partitionMentionIdentitiesByLocalTrust } from "./mentionIdentityTrust";
/**
* Ask the relay which names it holds for each pubkey.
*
* Reads the per-pubkey entries `useUsersBatchQuery` maintains before spending
* a request: the surface a mention was copied *from* already resolved that
* profile to render its chip, so the common cross-channel paste answers from
* cache. Entries this side considers stale are refetched rather than trusted.
*
* Read-only on the cache. Seeding it from here would hand the profile hooks a
* resolved entry they never wrote the offline label for.
*
* The caller only ever passes records that survived
* `parseMentionClipboardRecords`, so `pubkeys` is bounded by its record cap —
* a hostile clipboard cannot turn one paste into an unbounded fan-out.
*/
async function fetchTrustedMentionAliases(
queryClient: QueryClient,
pubkeys: readonly string[],
): Promise<Map<string, string[]>> {
const aliases = new Map<string, string[]>();
const toFetch: string[] = [];
const now = Date.now();
for (const pubkey of new Set(pubkeys)) {
const entry = queryClient.getQueryData<UsersBatchEntry>(
usersBatchEntryKey(pubkey),
);
if (entry && now - entry.fetchedAt < USERS_BATCH_ENTRY_FRESH_MS) {
aliases.set(pubkey, collectProfileAliases(entry.summary ?? undefined));
} else {
toFetch.push(pubkey);
}
}
if (toFetch.length === 0) return aliases;
const fresh = await getUsersBatch(toFetch);
for (const pubkey of toFetch) {
aliases.set(pubkey, collectProfileAliases(fresh.profiles[pubkey]));
}
return aliases;
}
/**
* Build the paste-side check that a copied `label → pubkey` pair is one this
* community actually holds — see `mentionIdentityTrust` for why a pair the
* clipboard merely *shows* is not evidence of anything.
*
* Two sources, in cost order:
*
* 1. Local trusted state — the mention candidates the composer would offer
* (channel roster, agent and persona directories, relay user search) and
* the profile lookup the surrounding surface renders from. Both are relay
* output, and both are already in hand.
* 2. The relay's own profile for the pubkey, for a pair no local directory can
* speak to. This is the case the feature exists for: a mention of someone
* who is not a member of the channel being pasted into.
*
* Anything neither source names is dropped. A pair naming a key this community
* has never seen — the shape a forged clipboard record takes — stays plain
* text.
*/
export function useVerifyMentionIdentities({
mentionCandidates,
profiles,
}: {
mentionCandidates: readonly MentionCandidate[];
profiles: UserProfileLookup | undefined;
}): VerifyMentionIdentities {
const queryClient = useQueryClient();
const resolveLocalAliases = React.useCallback(
(pubkey: string): string[] => {
const aliases = collectProfileAliases(profiles?.[pubkey]);
for (const candidate of mentionCandidates) {
if (!candidate.pubkey) continue;
if (normalizePubkey(candidate.pubkey) !== pubkey) continue;
// A managed agent renders under its persona name, which is no alias of
// its kind-0 profile — so a copy of that chip has to be verifiable
// against the directory that named it.
if (candidate.displayName) aliases.push(candidate.displayName);
if (candidate.personaName) aliases.push(candidate.personaName);
}
return aliases;
},
[mentionCandidates, profiles],
);
return React.useCallback(
async (records: readonly MentionIdentity[]) => {
const normalized = records.map((record) => ({
...record,
pubkey: normalizePubkey(record.pubkey),
}));
const { trusted, unresolved } = partitionMentionIdentitiesByLocalTrust(
normalized,
resolveLocalAliases,
);
if (unresolved.length === 0) return trusted;
const relayAliases = await fetchTrustedMentionAliases(
queryClient,
unresolved.map((record) => record.pubkey),
);
const { trusted: vouched } = partitionMentionIdentitiesByLocalTrust(
unresolved,
(pubkey) => relayAliases.get(pubkey) ?? [],
);
return [...trusted, ...vouched];
},
[queryClient, resolveLocalAliases],
);
}

View File

@@ -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}
</div>

View File

@@ -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;
@@ -556,6 +557,9 @@ function MessageComposerImpl({
if (isEditSubmissionLocked || voiceNote.statusRef.current !== "idle") {
return;
}
// An edit extracts from the same mention map a pasted identity binds
// into, so wait on any check still deciding. Bounded internally.
await mentions.settlePendingMentionBindings();
// Empty edits delete the message through handleEditSave.
await submitMessageEdit({
content: trimmed,
@@ -681,6 +685,7 @@ function MessageComposerImpl({
mentions.getDraftMentionRefs,
mentions.restoreDraftMentionRefs,
mentions.revalidateMentionPubkeys,
mentions.settlePendingMentionBindings,
voiceNote.statusRef,
]);
submitMessageRef.current = submitMessage;
@@ -791,6 +796,7 @@ function MessageComposerImpl({
);
useComposerPasteHandler({
editor: richText.editor,
bindMentionIdentities: mentions.bindPastedMentionIdentities,
scrollToBottom: scrollComposerToBottom,
setPendingImeta: voiceNote.setPendingImetaWhenIdle,
uploadFile: voiceNote.uploadFileWhenIdle,

View File

@@ -595,6 +595,7 @@ export const MessageRow = React.memo(
: undefined
}
onUnfollowThread={onUnfollowThread}
profiles={profiles}
reactionErrorMessage={reactionErrorMessage}
reactions={reactions}
/>

View File

@@ -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}

View File

@@ -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 (
<TooltipProvider>
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div
className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
onCopy={handleTimelineMentionCopy}
>
{showUnreadPill ? (
<div
className={cn(

View File

@@ -2,14 +2,18 @@ import * as React from "react";
import type { Editor } from "@tiptap/react";
import { handleAgentSnapshotPaste } from "@/features/messages/lib/agentSnapshotClipboard";
import type { BlobDescriptor } from "@/shared/api/tauri";
import {
hasMentionClipboardHtml,
normalizeMentionClipboardHtml,
} from "@/features/messages/lib/normalizeMentionClipboard";
import { hasMentionClipboardHtml } from "@/features/messages/lib/normalizeMentionClipboard";
import { handleMentionClipboardPaste } from "@/features/messages/lib/mentionClipboardPaste";
import type { BindPastedMentionIdentities } from "@/features/messages/lib/mentionPasteBinding";
import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard";
export function useComposerPasteHandler(options: {
editor: Editor | null;
/**
* Teaches the composer each `name → pubkey` pair a Buzz copy carried, once
* trusted state vouches for it. Without it, a paste binds nothing.
*/
bindMentionIdentities?: BindPastedMentionIdentities;
scrollToBottom: () => void;
setPendingImeta: (
update: (current: BlobDescriptor[]) => BlobDescriptor[],
@@ -18,6 +22,8 @@ export function useComposerPasteHandler(options: {
}) {
const uploadFileRef = React.useRef(options.uploadFile);
uploadFileRef.current = options.uploadFile;
const bindMentionIdentitiesRef = React.useRef(options.bindMentionIdentities);
bindMentionIdentitiesRef.current = options.bindMentionIdentities;
React.useEffect(() => {
const editor = options.editor;
if (!editor) return;
@@ -57,13 +63,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({
bindMentionIdentities: bindMentionIdentitiesRef.current,
clipboardData,
preventDefault: () => event.preventDefault(),
view,
});
}
if ((event.clipboardData?.getData("text/plain") ?? "").includes("\n"))
if ((clipboardData?.getData("text/plain") ?? "").includes("\n"))
options.scrollToBottom();
return false;
},

View File

@@ -667,6 +667,13 @@ export function useMentionSendFlow({
activePreparedLinkPreviews.add(preparedLinkPreviews);
}
try {
if (isSendCancelled()) return;
// Every extraction below reads the mention map, and a pasted identity
// can still be verifying — the relay round trip for a non-member is
// exactly the case this feature exists for. Sending first would
// publish a readable `@Label` with no `p` tag. Bounded inside, so a
// lookup that never answers delays the send rather than blocking it.
await mentions.settlePendingMentionBindings();
if (isSendCancelled()) return;
const dmThreadAgentMentionErrorMessage = dmThreadAgentMentionError({
trimmed,
@@ -809,6 +816,7 @@ export function useMentionSendFlow({
mentions.isManagedAgentPubkey,
mentions.memberPubkeys,
mentions.getDraftMentionRefs,
mentions.settlePendingMentionBindings,
onPrepareSendChannel,
activePreparedLinkPreviews,
],

View File

@@ -296,6 +296,14 @@ export const usersBatchEntryKey = (pubkey: string) => [
pubkey,
];
/**
* How long a per-pubkey entry answers without a refetch. Exported so readers
* outside this hook (the clipboard's paste-side identity check) apply the same
* freshness rule rather than trusting an entry this hook already considers
* stale.
*/
export const USERS_BATCH_ENTRY_FRESH_MS = 10 * 60_000;
/**
* Drop the per-pubkey delta-fetch entries so the next `useUsersBatchQuery`
* run re-fetches these profiles from the relay. Must be called anywhere a
@@ -352,7 +360,7 @@ export function useUsersBatchQuery(
const entry = queryClient.getQueryData<UsersBatchEntry>(
usersBatchEntryKey(pubkey),
);
if (entry && now - entry.fetchedAt < 10 * 60_000) {
if (entry && now - entry.fetchedAt < USERS_BATCH_ENTRY_FRESH_MS) {
if (entry.summary) profiles[pubkey] = entry.summary;
else missing.push(pubkey);
} else {
@@ -385,7 +393,7 @@ export function useUsersBatchQuery(
relayUrl,
normalizedPubkeys,
),
staleTime: 10 * 60_000,
staleTime: USERS_BATCH_ENTRY_FRESH_MS,
gcTime: 5 * 60 * 1_000,
// Override the global defaults: a cold channel needs profiles to render
// correctly, so a single failed attempt must not leave raw npubs/broken

View File

@@ -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<void> {
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<void> {
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);
})

View File

@@ -17,8 +17,14 @@ export function getMentionTagPubkey(tag: string[]): string | null {
* fact), so a single-alias match leaves chips that render but never resolve
* to a pubkey. Emitting every known alias — display name, kind-0 `name`, and
* the NIP-05 local part — keeps rendered chips and pubkey resolution in sync.
*
* Exported because the clipboard's paste-side trust check has to ask the same
* question in reverse: a copied `label → pubkey` pair is only believable if
* the community's own profile for that pubkey answers to that label. Deriving
* both from this one alias set means a legitimate copy of a chip rendered off
* an alias cannot be refused for naming that alias.
*/
function collectProfileAliases(
export function collectProfileAliases(
profile: UserProfileSummary | undefined,
): string[] {
if (!profile) {

View File

@@ -31,7 +31,17 @@ type InlineChipProps =
as: "button";
});
/** Shared visual primitive for mention, channel, and Buzz permalink chips. */
/**
* Shared visual primitive for mention, channel, and Buzz permalink chips.
*
* Copy reads a chip's text back against the label its `data-*-label` attribute
* declares (`matchChipTextToLabel`) to tell a whole chip from the fragment a
* boundary-crossing selection leaves. Text rendered here that isn't part of
* that label — a count badge, a glyph with a text fallback — makes a whole chip
* look like a fragment, which silently drops its identity from the clipboard.
* Render such additions as `aria-hidden` CSS content, or teach the predicate
* the new form.
*/
export function InlineChip({
as = "span",
children,

View File

@@ -410,6 +410,8 @@ function ChannelReferenceChip({
<ChannelMetadataTooltip channel={channel}>
<BuzzLinkChip
data-channel-link=""
// The chip drops the `#` for display; copy restores it from here.
data-channel-label={channelName}
href={channel ? buildChannelLink(channel.id) : undefined}
icon="channel"
aria-label={

View File

@@ -5,7 +5,14 @@ import { InlineChip } from "@/shared/ui/InlineChip";
import { cn } from "@/shared/lib/cn";
import { useMarkdownRuntime } from "./runtimeContext";
/** Exact-identity mention chip and its shared management provenance. */
/**
* Exact-identity mention chip and its shared management provenance.
*
* The chip drops the `@` for display, so the identity it resolved is also
* published as inert `data-*` attributes: the timeline copy handler reads them
* to restore the sigil and to carry the exact pubkey into the clipboard's HTML
* flavor. They change neither the visuals nor the screen-reader output.
*/
export function MarkdownMention({
children,
interactive,
@@ -28,6 +35,11 @@ export function MarkdownMention({
const mentionNode = (
<InlineChip
data-mention=""
data-mention-kind={
pubkey === undefined ? undefined : isAgentMention ? "agent" : "human"
}
data-mention-label={mentionLabel}
data-mention-pubkey={pubkey}
className={cn(isAgentMention && "agent-mention-highlight")}
icon={isAgentMention ? "agent" : "human"}
interactive={opensProfile}

View File

@@ -1198,9 +1198,46 @@ function updateMockRelayMembershipFromAdminEvent(event: RelayEvent): boolean {
return false;
}
/**
* Mirror the native clipboard command's dual-flavor write.
*
* `navigator.clipboard.writeText` can only carry the plain flavor, so specs
* asserting on the identity sidecar read the captured payload instead. The
* rich write is still attempted so real paste round-trips work.
*/
async function writeClipboardFlavors({
html,
text,
}: {
html?: string;
text: string;
}): Promise<void> {
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;
@@ -1551,6 +1588,12 @@ declare global {
/** Number of `get_thread_replies` calls currently held by
* `deferThreadReplies`. */
__BUZZ_E2E_THREAD_REPLIES_PENDING__?: () => number;
/** Start or stop holding `get_users_batch` responses. Turning the hold off
* flushes everything held; either call returns the number released, so a
* spec can prove a relay identity lookup was genuinely pinned open. */
__BUZZ_E2E_HOLD_USERS_BATCH__?: (hold: boolean) => number;
/** Number of `get_users_batch` calls currently held. */
__BUZZ_E2E_USERS_BATCH_PENDING__?: () => number;
/** Uploads that passed mock-native registration and began relay work. */
__BUZZ_E2E_LINK_PREVIEW_UPLOAD_STARTS__?: number;
/** Hold renderer-owned media fetches until their cancellation command. */
@@ -1628,6 +1671,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,
@@ -1667,6 +1715,14 @@ let deferredGetEventQueue: DeferredGetEvent[] = [];
let deferredLinkPreviewMetadataQueue: Array<() => void> = [];
let deferredLinkPreviewUploadQueue: Array<() => void> = [];
let deferredThreadRepliesQueue: Array<() => void> = [];
// ── get_users_batch hold seam ───────────────────────────────────────────────
// Toggled at runtime by `__BUZZ_E2E_HOLD_USERS_BATCH__(hold)` rather than fixed
// at boot by a mock-config flag, because a mention-identity spec needs both
// sides of the hold in one page: profiles resolved normally first (so one paste
// verifies instantly from local state), then the relay lookup pinned open (so a
// second paste of the same label is provably still deciding).
let holdUsersBatch = false;
let heldUsersBatchReleases: Array<() => void> = [];
// Starts currently held behind `startManagedAgentDelayMs`, releasable early
// via `__BUZZ_E2E_RELEASE_MANAGED_AGENT_STARTS__()`: a spec that holds a
// start across a community round-trip needs the hold long enough to be
@@ -1691,6 +1747,7 @@ const mockDisplayNames = new Map<string, string>([
[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([
@@ -4156,6 +4213,7 @@ const mockPresence = new Map<string, PresenceStatus>([
[PROFILE_ONLY_AGENT_PUBKEY, "online"],
[OWNED_RELAY_AGENT_PUBKEY, "online"],
[OUTSIDER_PUBKEY, "offline"],
[MULTI_WORD_NON_MEMBER_PUBKEY, "offline"],
]);
const mockFeedOverrides: RawHomeFeedResponse["feed"] = {
mentions: [],
@@ -6866,6 +6924,11 @@ async function handleGetUsersBatch(
window.setTimeout(resolve, usersBatchDelayMs);
});
}
if (holdUsersBatch) {
await new Promise<void>((resolve) => {
heldUsersBatchReleases.push(resolve);
});
}
const identity = getIdentity(config);
if (!identity) {
@@ -11256,6 +11319,8 @@ export function maybeInstallE2eTauriMocks() {
deferredLinkPreviewMetadataQueue = [];
deferredLinkPreviewUploadQueue = [];
deferredThreadRepliesQueue = [];
holdUsersBatch = false;
heldUsersBatchReleases = [];
cancelledMediaUploadIds = new Set<string>();
for (const controller of mockMediaFetchControllers.values()) {
controller.abort();
@@ -11281,6 +11346,15 @@ export function maybeInstallE2eTauriMocks() {
};
window.__BUZZ_E2E_THREAD_REPLIES_PENDING__ = () =>
deferredThreadRepliesQueue.length;
window.__BUZZ_E2E_HOLD_USERS_BATCH__ = (hold: boolean) => {
holdUsersBatch = hold;
// Releasing on the way out of the hold, not on the way in, is what lets a
// spec keep one lookup pinned while another resolves from local state.
const queued = hold ? [] : heldUsersBatchReleases.splice(0);
for (const release of queued) release();
return queued.length;
};
window.__BUZZ_E2E_USERS_BATCH_PENDING__ = () => heldUsersBatchReleases.length;
mockGlobalAgentConfig = config.mock?.globalAgentConfig
? { ...config.mock.globalAgentConfig }
: null;
@@ -14419,7 +14493,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();

File diff suppressed because it is too large Load Diff

View File

@@ -12,3 +12,31 @@ 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`.