diff --git a/desktop/src/features/messages/lib/mentionClipboard.test.mjs b/desktop/src/features/messages/lib/mentionClipboard.test.mjs index 4d8f1830aa2..39f16c96791 100644 --- a/desktop/src/features/messages/lib/mentionClipboard.test.mjs +++ b/desktop/src/features/messages/lib/mentionClipboard.test.mjs @@ -440,3 +440,25 @@ test("does not treat an uncapped label's ellipsis as a truncation", () => { // whole must not gain a second accepted form. assert.equal(matchChipTextToLabel("John…", "John Smith", "@"), "fragment"); }); + +test("whole compact mention labels are restored only for their declared exact key", () => { + const key = `150b20bd${"a".repeat(52)}15dc`; + const label = `Scout (${key}) 2`; + const compact = "Scout (150b20bd…15dc) 2"; + assert.equal(matchChipTextToLabel(compact, label, "@", key), "truncated"); + assert.equal( + matchChipTextToLabel(`@${compact}`, label, "@", key), + "truncated", + ); + assert.equal(matchChipTextToLabel(compact, label, "@"), "fragment"); + assert.equal( + matchChipTextToLabel(compact, label, "@", "b".repeat(64)), + "fragment", + ); + assert.equal(matchChipTextToLabel(compact, label, "#", key), "fragment"); + assert.equal( + matchChipTextToLabel("Scout (150b20bd…15dc)", label, "@", key), + "fragment", + ); + assert.equal(matchChipTextToLabel("Scout", label, "@", key), "fragment"); +}); diff --git a/desktop/src/features/messages/lib/mentionClipboard.ts b/desktop/src/features/messages/lib/mentionClipboard.ts index 19309a9cbb6..8886bf8c29d 100644 --- a/desktop/src/features/messages/lib/mentionClipboard.ts +++ b/desktop/src/features/messages/lib/mentionClipboard.ts @@ -1,3 +1,4 @@ +import { formatMentionDisplayLabel } from "@/shared/lib/mentionDisplay"; import { truncateInlineChipLabel } from "@/shared/ui/mentionChip"; import { getMentionOffsets } from "./hasMention"; @@ -77,6 +78,7 @@ export function matchChipTextToLabel( text: string, label: string, sigil: "@" | "#", + pubkey?: string, ): ChipTextMatch { const body = canonicalMentionLabel(text); const matches = (form: string) => body === form || body === `${sigil}${form}`; @@ -87,6 +89,13 @@ export function matchChipTextToLabel( if (truncated !== label && matches(canonicalMentionLabel(truncated))) { return "truncated"; } + // Read-only mentions abbreviate a bound key, never the identity carried by + // the clipboard. Restore the full literal label only for the whole display. + const compact = + sigil === "@" ? formatMentionDisplayLabel(label, pubkey) : label; + if (compact !== label && matches(canonicalMentionLabel(compact))) { + return "truncated"; + } return "fragment"; } diff --git a/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs b/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs index 81ccfb4b3df..3e0563ab96c 100644 --- a/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs +++ b/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs @@ -265,3 +265,19 @@ test("an ordinary chip still flattens to a registrable mention", () => { [JOHN_SMITH_PUBKEY], ); }); + +test("compact mention paste expands only complete bound labels", () => { + const key = `150b20bd${"a".repeat(52)}15dc`; + const label = `Scout (${key}) 2`; + const html = (text) => + `${text}`; + assert.equal( + normalizeMentionClipboardContent(html("Scout (150b20bd…15dc) 2")).text, + `@${label}`, + ); + assert.equal( + normalizeMentionClipboardContent(html("Scout (150b20bd…15dc)")).text, + "Scout (150b20bd…15dc)", + ); +}); diff --git a/desktop/src/features/messages/lib/normalizeMentionClipboard.ts b/desktop/src/features/messages/lib/normalizeMentionClipboard.ts index 55472f705c0..1a6f7403b29 100644 --- a/desktop/src/features/messages/lib/normalizeMentionClipboard.ts +++ b/desktop/src/features/messages/lib/normalizeMentionClipboard.ts @@ -2,6 +2,7 @@ import { CHANNEL_LABEL_ATTRIBUTE, matchChipTextToLabel, MENTION_LABEL_ATTRIBUTE, + MENTION_PUBKEY_ATTRIBUTE, } from "./mentionClipboard"; /** @@ -173,7 +174,14 @@ export function normalizeMentionClipboardContent( // 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); + label === null + ? "full" + : matchChipTextToLabel( + text, + label, + sigil, + el.getAttribute(MENTION_PUBKEY_ATTRIBUTE) ?? undefined, + ); span.textContent = match === "fragment" ? text diff --git a/desktop/src/features/messages/lib/timelineMentionCopy.test.mjs b/desktop/src/features/messages/lib/timelineMentionCopy.test.mjs index 2c19dffbedd..4f8c05842aa 100644 --- a/desktop/src/features/messages/lib/timelineMentionCopy.test.mjs +++ b/desktop/src/features/messages/lib/timelineMentionCopy.test.mjs @@ -187,3 +187,18 @@ test("copy inlines a blockified chip but preserves its block ancestor", () => { else delete prototype.innerText; } }); + +test("copy expands compact key text but preserves the exact label and identity", () => { + const label = `Scout (${JOHN_SMITH_PUBKEY}) 2`; + const flavors = copyRenderedBody( + `` + + 'Scout (7c7c7c7c…7c7c) 2', + ); + assert.ok(flavors); + assert.ok(flavors.html.includes(`@${label}`)); + assert.ok( + flavors.html.includes(`data-mention-pubkey="${JOHN_SMITH_PUBKEY}"`), + ); + assert.ok(!flavors.html.includes("…")); +}); diff --git a/desktop/src/features/messages/lib/timelineMentionCopy.ts b/desktop/src/features/messages/lib/timelineMentionCopy.ts index b7c137bc2fd..107360a1141 100644 --- a/desktop/src/features/messages/lib/timelineMentionCopy.ts +++ b/desktop/src/features/messages/lib/timelineMentionCopy.ts @@ -50,7 +50,12 @@ function restoreChipSigils(root: HTMLElement): boolean { const label = element.getAttribute(MENTION_LABEL_ATTRIBUTE); if ( label && - matchChipTextToLabel(element.textContent ?? "", label, "@") !== "fragment" + matchChipTextToLabel( + element.textContent ?? "", + label, + "@", + element.getAttribute(MENTION_PUBKEY_ATTRIBUTE) ?? undefined, + ) !== "fragment" ) { element.textContent = `@${label}`; restored = true; diff --git a/desktop/src/shared/lib/mentionDisplay.test.mjs b/desktop/src/shared/lib/mentionDisplay.test.mjs new file mode 100644 index 00000000000..20e0f17eb0b --- /dev/null +++ b/desktop/src/shared/lib/mentionDisplay.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { formatMentionDisplayLabel } from "./mentionDisplay.ts"; +import { truncatePubkey } from "./pubkey.ts"; + +const KEY = `150b20bd${"a".repeat(52)}15dc`; + +test("compact mention display uses the member-list formatter and keeps collision suffixes", () => { + for (const suffix of ["", " 2", " 10"]) { + assert.equal( + formatMentionDisplayLabel(`Bad Janet (${KEY})${suffix}`, KEY), + `Bad Janet (${truncatePubkey(KEY)})${suffix}`, + ); + } + assert.equal(formatMentionDisplayLabel(KEY, KEY), truncatePubkey(KEY)); + assert.equal( + formatMentionDisplayLabel(KEY.toUpperCase(), KEY), + truncatePubkey(KEY.toUpperCase()), + ); +}); + +test("display leaves unbound, mismatched, malformed and ordinary labels literal", () => { + for (const [label, key] of [ + [`Bad Janet (${KEY})`, undefined], + [`Bad Janet (${KEY})`, "b".repeat(64)], + [`Bad Janet (${KEY})`, "bad-key"], + [`Bad Janet (${KEY}) 1`, KEY], + [`Bad Janet (${KEY}) notes`, KEY], + [`Release ${KEY}`, KEY], + ["Bad Janet", KEY], + ]) + assert.equal(formatMentionDisplayLabel(label, key), label); +}); + +test("matching compact keys do not become identity keys", () => { + const other = KEY.replace("aaaa", "bbbb"); + assert.notEqual(KEY, other); + assert.equal( + formatMentionDisplayLabel(`Scout (${KEY})`, KEY), + formatMentionDisplayLabel(`Scout (${other})`, other), + ); + assert.equal( + formatMentionDisplayLabel(`Scout (${KEY})`, other), + `Scout (${KEY})`, + ); +}); diff --git a/desktop/src/shared/lib/mentionDisplay.ts b/desktop/src/shared/lib/mentionDisplay.ts new file mode 100644 index 00000000000..10d15643ad4 --- /dev/null +++ b/desktop/src/shared/lib/mentionDisplay.ts @@ -0,0 +1,17 @@ +import { truncatePubkey } from "./pubkey"; + +/** Compact only a bound mention's key; its literal label remains authoritative. */ +export function formatMentionDisplayLabel( + label: string, + pubkey: string | undefined, +): string { + if (!pubkey || !/^[0-9a-f]{64}$/i.test(pubkey)) return label; + if (label.toLowerCase() === pubkey.toLowerCase()) { + return truncatePubkey(label); + } + const qualified = label.match( + /^(.*) \(([0-9a-f]{64})\)((?: (?:[2-9]|[1-9][0-9]+))?)$/i, + ); + if (qualified?.[2].toLowerCase() !== pubkey.toLowerCase()) return label; + return `${qualified[1]} (${truncatePubkey(qualified[2])})${qualified[3]}`; +} diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index 44d6b16aab7..08861a4daa1 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -1400,7 +1400,12 @@ test("resolved human mentions replace the authored at-sign with the shared icon" ); assert.match(html, /data-mention=""/); - assert.match(html, /inline-chip-icon-human/); + assert.match( + html, + /inline-chip-leading-fragment[^>]*inline-chip-icon-human[^>]*>alice<\/span>/, + ); + assert.match(html, /aria-label="alice"/); + assert.doesNotMatch(html, /aria-hidden="true"[^>]*>alicealice@alice assert.match(html, /data-mention=""/); assert.match(html, /agent-mention-highlight/); - assert.match(html, /inline-chip-icon-agent/); + assert.match( + html, + /inline-chip-leading-fragment[^>]*inline-chip-icon-agent[^>]*>alice<\/span>/, + ); + assert.match(html, /aria-label="alice"/); + assert.doesNotMatch(html, /aria-hidden="true"[^>]*>alicealice@alice - {mentionLabel} + {/* Wrapping chips hide the outer icon; keep it with a bounded prefix. */} + + {displayLabel.slice(0, leadingEnd)} + + {displayLabel.slice(leadingEnd)} {isAgentMention ? : null} ); diff --git a/desktop/src/shared/ui/markdownMentionDisplay.test.mjs b/desktop/src/shared/ui/markdownMentionDisplay.test.mjs new file mode 100644 index 00000000000..bf96caeee00 --- /dev/null +++ b/desktop/src/shared/ui/markdownMentionDisplay.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { truncatePubkey } from "../lib/pubkey.ts"; +import { createMarkdownComponents } from "./markdown.tsx"; +import { renderCachedMarkdown } from "./markdown/nodeCache.ts"; +import { MarkdownRuntimeContext } from "./markdown/runtimeContext.ts"; + +const KEY = `150b20bd${"a".repeat(52)}15dc`; + +for (const agent of [false, true]) { + test(`rendered ${agent ? "agent" : "human"} abbreviates a bound key without changing its metadata`, () => { + const label = `Scout (${KEY}) 2`; + const name = label.toLowerCase(); + const html = renderToStaticMarkup( + React.createElement( + MarkdownRuntimeContext.Provider, + { + value: { + channels: [], + mentionPubkeysByName: { [name]: KEY }, + agentMentionPubkeysByName: agent ? { [name]: KEY } : {}, + }, + }, + renderCachedMarkdown({ + content: `Ask @${label}`, + mentionNames: [label], + components: createMarkdownComponents(false, false), + variant: `compact-mention-${agent}`, + }), + ), + ); + assert.equal( + html.replace(/<[^>]+>/g, ""), + `Ask Scout (${truncatePubkey(KEY)}) 2`, + ); + assert.ok(html.includes(`data-mention-label="${label}"`)); + assert.ok(html.includes(`data-mention-pubkey="${KEY}"`)); + assert.ok(html.includes(`title="${label}"`)); + assert.ok(html.includes(`aria-label="${label}"`)); + assert.match( + html, + new RegExp( + `inline-chip-leading-fragment[^>]*inline-chip-icon-${agent ? "agent" : "human"}`, + ), + ); + }); +} + +test("an unresolved qualified mention stays literal rather than claiming an abbreviated identity", () => { + const label = `Scout (${KEY})`; + const html = renderToStaticMarkup( + React.createElement( + MarkdownRuntimeContext.Provider, + { + value: { channels: [], mentionPubkeysByName: {} }, + }, + renderCachedMarkdown({ + content: `Ask @${label}`, + mentionNames: [label], + components: createMarkdownComponents(false, false), + variant: "unresolved-compact-mention", + }), + ), + ); + assert.ok(html.includes(`@${label}`)); + assert.doesNotMatch(html, /data-mention=/); +}); diff --git a/desktop/tests/e2e/cloud-provenance.spec.ts b/desktop/tests/e2e/cloud-provenance.spec.ts index d04b48cf0ab..b8914b491de 100644 --- a/desktop/tests/e2e/cloud-provenance.spec.ts +++ b/desktop/tests/e2e/cloud-provenance.spec.ts @@ -139,6 +139,13 @@ for (const agentListDelayMs of [0, 6_000]) { ).toBeVisible(); const chip = article.locator("[data-mention]"); await expect(chip.locator("svg.lucide-cloud")).toBeVisible(); + const leading = chip.locator(".inline-chip-leading-fragment"); + await expect(leading).toHaveText("Remot"); + expect( + await leading.evaluate( + (element) => getComputedStyle(element, "::before").display, + ), + ).toBe("block"); await waitForAnimations(page); await article.screenshot({ path: testInfo.outputPath("cloud-author-chip.png"), diff --git a/desktop/tests/e2e/mention-clipboard.spec.ts b/desktop/tests/e2e/mention-clipboard.spec.ts index 3bc4aa28859..19cd599c08e 100644 --- a/desktop/tests/e2e/mention-clipboard.spec.ts +++ b/desktop/tests/e2e/mention-clipboard.spec.ts @@ -133,8 +133,11 @@ async function copyFromTimeline( selection.removeAllRanges(); const range = document.createRange(); if (selectPartialChip) { - const label = chip.firstChild; - if (!label) throw new Error("Mention chip has no text node."); + const walker = document.createTreeWalker(chip, NodeFilter.SHOW_TEXT); + const label = walker.nextNode(); + if (!label?.nodeValue?.startsWith("John")) { + throw new Error("Mention chip has no leading John text node."); + } range.setStart(label, 0); range.setEnd(label, 4); } else { @@ -202,6 +205,11 @@ async function openInboxMentionItem(page: Page) { }, ); + const preview = page + .getByTestId(`home-inbox-item-${item.id}`) + .locator("[data-mention]"); + await expect(preview).toHaveText("John Smith"); + expect(await preview.ariaSnapshot()).toContain("John"); await page.getByTestId(`home-inbox-item-${item.id}`).click(); // The inbox resolves a non-member's display name off its own profile batch, // so wait for the chip's identity rather than for the row — same setup @@ -210,6 +218,7 @@ async function openInboxMentionItem(page: Page) { .getByTestId("home-inbox-detail-scroll") .locator(`[data-mention-pubkey="${JOHN_SMITH_PUBKEY}"]`); await expect(chip).toHaveText("John Smith", { timeout: 15_000 }); + expect(await chip.ariaSnapshot()).toContain("John"); return item; } @@ -930,7 +939,7 @@ test("a boundary-crossing default copy pastes its chip fragment without a sigil" return nodes; }; const label = textNodesUnder(chip).find((node) => - node.nodeValue?.includes("John Smith"), + node.nodeValue?.includes("Smith"), ); const tail = textNodesUnder(body).find((node) => node.nodeValue?.includes("fixed the bug"), diff --git a/desktop/tests/e2e/mention-recipients.spec.ts b/desktop/tests/e2e/mention-recipients.spec.ts index 6c2ebaa565b..0e29ed49327 100644 --- a/desktop/tests/e2e/mention-recipients.spec.ts +++ b/desktop/tests/e2e/mention-recipients.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page } from "@playwright/test"; +import { truncatePubkey } from "../../src/shared/lib/pubkey"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -7,7 +8,7 @@ const SECOND = TEST_IDENTITIES.bob.pubkey; const AMBIGUOUS = "The mention @Scout is ambiguous. Choose a recipient from the mention picker."; -async function install(page: Page, channel = "general") { +async function install(page: Page, channel = "general", agents = false) { await installMockBridge(page, { managedAgents: channel === "watercooler" @@ -17,10 +18,18 @@ async function install(page: Page, channel = "general") { status: "running", channelNames: ["watercooler"], })) - : [], + : agents + ? [FIRST, SECOND].map((pubkey) => ({ + pubkey, + name: "Scout", + status: "running", + channelNames: [channel], + })) + : [], searchProfiles: [FIRST, SECOND].map((pubkey) => ({ pubkey, displayName: "Scout", + isAgent: agents, })), }); await page.goto("/"); @@ -419,12 +428,19 @@ test("editing to a longer typed member drops the original shorter reference", as await expect(row).toContainText("Scout Jones hello"); }); -for (const scale of [1, 1.5]) { - test(`exact-key chips wrap in narrow composer, sent message and reopen at ${scale}x text`, async ({ +// The default relay directory knows FIRST as an agent; SECOND is a human. +// The agent variant makes SECOND managed too, covering a qualified bot label. +for (const { kind, scale } of [ + { kind: "mixed", scale: 1 }, + { kind: "mixed", scale: 1.5 }, + { kind: "agent", scale: 1 }, + { kind: "agent", scale: 1.5 }, +]) { + test(`exact-key ${kind} chips wrap in narrow composer, sent message and reopen at ${scale}x text`, async ({ page, }, testInfo) => { await page.setViewportSize({ width: 800, height: 900 }); - await install(page); + await install(page, "general", kind === "agent"); await page.evaluate((scale) => { document.documentElement.style.fontSize = `${16 * scale}px`; }, scale); @@ -460,6 +476,13 @@ for (const scale of [1, 1.5]) { host: import("@playwright/test").Locator, stage: string, ) => { + if (stage === "sent") { + await expect(host.locator("[data-mention]")).toHaveCount(2); + await expect(host.locator("[data-mention]").last()).toHaveAttribute( + "data-mention-kind", + kind === "agent" ? "agent" : "human", + ); + } const result = await geometry(host); expect(result.chips.length).toBeGreaterThanOrEqual(2); expect(result.scrollWidth).toBeLessThanOrEqual(result.clientWidth + 1); @@ -475,7 +498,35 @@ for (const scale of [1, 1.5]) { expect(rect.right).toBeLessThanOrEqual(result.width + 1); } } - if (stage !== "sent") { + if (stage === "sent") { + for (const chip of await host.locator("[data-mention]").all()) { + const expectedKind = + kind === "agent" || + (await chip.getAttribute("data-mention-pubkey")) === FIRST + ? "agent" + : "human"; + await expect(chip).toHaveAttribute("data-mention-kind", expectedKind); + const leading = chip.locator(".inline-chip-leading-fragment"); + await expect(leading).toHaveText("Scout"); + expect(await leading.ariaSnapshot()).toContain("Scout"); + const icon = await leading.evaluate((element) => { + const style = getComputedStyle(element, "::before"); + return { + display: style.display, + mask: style.maskImage, + width: parseFloat(style.width), + height: parseFloat(style.height), + }; + }); + expect(icon.display).toBe("block"); + expect(icon.mask).toContain("data:image/svg+xml"); + expect(icon.width).toBeGreaterThan(0); + expect(icon.height).toBeGreaterThan(0); + await expect(leading).toHaveClass( + new RegExp(`inline-chip-icon-${expectedKind}`), + ); + } + } else { for (const prefix of await host .locator(".mention-prefix-hidden") .all()) { @@ -495,7 +546,7 @@ for (const scale of [1, 1.5]) { }); await waitForAnimations(page); await page.screenshot({ - path: `test-results/mention-recipients/layout-${scale}-${stage}.png`, + path: `test-results/mention-recipients/layout-${kind}-${scale}-${stage}.png`, }); }; await expect(input).toHaveText(content); @@ -514,6 +565,13 @@ for (const scale of [1, 1.5]) { .filter({ hasText: "layout journey" }) .last(); await assertFits(markdown, "sent"); + const qualifiedChip = row.locator("[data-mention]").last(); + await expect(qualifiedChip).toHaveText(`Scout (${truncatePubkey(SECOND)})`); + await expect(qualifiedChip).toHaveAttribute( + "data-mention-label", + `Scout (${SECOND})`, + ); + await expect(qualifiedChip).toHaveAttribute("title", `Scout (${SECOND})`); await expect(row.locator("[data-mention]").last()).toHaveAttribute( "aria-label", `Scout (${SECOND})`, @@ -815,7 +873,7 @@ for (const mismatchedKey of [false, true]) { .getByTestId("message-row") .filter({ hasText: "qualified clipboard roundtrip" }) .locator(`[data-mention-pubkey="${SECOND}"]`); - await expect(chip).toHaveText(`Scout (${SECOND})`); + await expect(chip).toHaveText(`Scout (${truncatePubkey(SECOND)})`); const flavors = await chip.evaluate((element) => { const range = document.createRange(); range.selectNode(element); @@ -870,3 +928,118 @@ for (const mismatchedKey of [false, true]) { .toEqual([mismatchedKey ? [] : [SECOND]]); }); } + +for (const partial of [false, true]) { + test(`matching abbreviated keys ${partial ? "do not bind a partial copy" : "retain separate exact recipients through copy and paste"}`, async ({ + page, + }) => { + const keys = ["a", "b"].map((middle) => `150b20bd${middle.repeat(52)}15dc`); + await installMockBridge(page, { + searchProfiles: keys.map((pubkey) => ({ pubkey, displayName: "Scout" })), + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.waitForFunction(() => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + }), + ); + await page.evaluate((keys) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: `@Scout (${keys[0]}) and @Scout (${keys[1]}) compact collision`, + mentionPubkeys: keys, + }); + }, keys); + const row = page + .getByTestId("message-row") + .filter({ hasText: "compact collision" }); + for (const key of keys) { + const chip = row.locator(`[data-mention-pubkey="${key}"]`); + await expect(chip).toHaveText(`Scout (${truncatePubkey(key)})`); + await expect(chip).toHaveAttribute("title", `Scout (${key})`); + const flavors = await chip.evaluate((element, partial) => { + const range = document.createRange(); + range.selectNode(element); + if (partial) { + const leadingText = document + .createTreeWalker(element, NodeFilter.SHOW_TEXT) + .nextNode(); + if (!leadingText) throw new Error("Missing mention text"); + range.setStart(leadingText, 0); + range.setEnd(leadingText, 5); + } + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + const clipboardData = new DataTransfer(); + const event = new ClipboardEvent("copy", { + bubbles: true, + cancelable: true, + clipboardData, + }); + element.dispatchEvent(event); + // Model the browser's default HTML serialization if our handler declines. + const fallback = document.createElement("div"); + fallback.append(range.cloneContents()); + return { + handled: event.defaultPrevented, + text: event.defaultPrevented + ? clipboardData.getData("text/plain") + : (selection?.toString() ?? ""), + html: event.defaultPrevented + ? clipboardData.getData("text/html") + : fallback.innerHTML, + }; + }, partial); + expect(flavors.handled).toBe(!partial); + expect(flavors.text.trim()).toBe(partial ? "Scout" : `@Scout (${key})`); + const input = page.getByTestId("message-input"); + await input.focus(); + await input.evaluate((element, flavors) => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", flavors.text); + clipboardData.setData("text/html", flavors.html); + element.dispatchEvent( + new ClipboardEvent("paste", { + bubbles: true, + cancelable: true, + clipboardData, + }), + ); + }, flavors); + const marker = ` copied-${keys.indexOf(key)}`; + await page.keyboard.type(marker); + const content = `${flavors.text}${marker}`; + await expect(input).toHaveText(content); + await page.getByTestId("send-message").click(); + if (!partial) { + await page + .getByRole("alertdialog") + .getByRole("button", { name: "Invite", exact: true }) + .click(); + } + // Chromium can preserve the typed separator as NBSP after a rich paste. + // Assert the full literal body and exact tags, tolerating only that space. + await expect + .poll(() => + page.evaluate( + (marker) => + (window.__BUZZ_E2E_SIGNED_EVENTS__ ?? []) + .filter( + (event) => + event.kind === 9 && event.content.endsWith(marker.trim()), + ) + .map((event) => ({ + content: event.content.replace(/\u00a0/g, " ").trim(), + keys: event.tags + .filter((tag) => tag[0] === "p") + .map((tag) => tag[1]), + })), + marker, + ), + ) + .toEqual([{ content: content.trim(), keys: partial ? [] : [key] }]); + } + }); +} diff --git a/docs/mention-editor.md b/docs/mention-editor.md index d9a4b54ccb1..9ee6339a7ee 100644 --- a/docs/mention-editor.md +++ b/docs/mention-editor.md @@ -117,9 +117,13 @@ Immutable annotated automatic-address metadata remains separate, and only previously delivered automatic addresses are forwarded. Snapshot bodies and full-key qualifiers alone never authorize an untagged recipient. -Full-key literal labels remain intact in the composer and on the wire. Composer -and rendered mention chips break between characters within narrow line boxes; -rendered chips expose the complete label through their accessible name/title. +Full-key literal labels remain intact in the composer and on the wire. Readonly +mention chips abbreviate only their bound public key with the shared +`truncatePubkey` display form (eight leading characters, ellipsis, four trailing). +The complete literal label and exact key remain in metadata, title and profile +target; whole-chip copy restores the full label for paste/edit round trips. +Abbreviations are recognition aids, never recipient lookup keys. Partial copies +remain plain text. Composer and rendered chips still wrap within narrow lines. The browser regression covers 800px windows at 100% and 150% root text size, send/reopen, and historical replacement followed by forwarding.