From 4afef8649ab11d60b423893e9e4f8cd36868d35a Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 16:36:17 -0400 Subject: [PATCH] fix(desktop): restore mention chip identity icons (#7338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Restore the missing **@ glyph for people and robot icon for agents** after #7133, and incorporate the requested compact public-key display. - Wrapping mention chips render their existing bounded icon-bearing leading fragment. - Readonly chips show bound keys using the same `8 leading…4 trailing` formatter as the channel member list: `Scout (150b20bd…15dc)`. - Full literal labels and exact keys remain authoritative in metadata, profile targets, title/accessible-name attributes, editor text, saved bodies and recipient tags. Display abbreviations are never used for recipient lookup. - Copy/paste restores the full literal label for a complete compact chip; partial selections remain plain text. Two keys sharing the same abbreviation still round-trip to their separate exact recipients. - No recipient-resolution, authorization, wire-format, composer, or CSS changes. Existing labels, icons, cloud markers and ordinary mentions remain intact. ## Verification Published candidate `9365ab9bc9d9c5d10802580cd576f8e378ff492f`, based on main `e09f715c9d0ee2cb7bf8a39061e601f3a502f588`: - **6,444 desktop unit tests passed** on this candidate's final source tree. - **44 mock-Chromium tests passed, zero retries in the final run** across mention recipients, clipboard and cloud provenance: exact recipient selection, ambiguity rejection, send/edit/reopen, forwarding, full/partial copy, mismatched-key rejection, matching-abbreviation collisions and 100%/150% narrow-window geometry. - TypeScript, desktop Biome/check guards, protected-feature production build and E2E build passed. - The compact-renderer regression fails with the formatting call removed. Original missing-icon and hidden-text accessibility regressions have red/green evidence. - Fresh self-review traced rendering, full-key metadata, copy classifier, paste normalization and identity trust. The same display formatter owns the accepted compact form on both clipboard sides. Iteration exposed an existing team-insertion separator flake (passed final full run) and two new fixture assumptions: non-member sends require invitation, and Chromium rich paste may retain an NBSP separator. Tests now exercise invitation and normalize only that separator when comparing the captured full body and exact tags; no product change was needed for either. Earlier local repository-wide `just ci` completed in two invocations because its initial call hit the ten-minute tool limit during Tauri compilation. Unchanged native/mobile/backend evidence is reused; the desktop delta received the full checks above and new remote CI. Native VoiceOver, real Tauri selection, dark theme and non-Chromium observation were not performed. Browser artifacts exercise real frontend with mocked Tauri/relay boundaries, not an installed release. ## Review and visual evidence Current-head CI and automated review must complete after this update; the old `2997bfb5` green results do not establish this new head. Required human review remains separate from agent approvals. No merge/install/restart authorization. Before/after icon evidence: https://github.com/block/buzz/pull/7338#issuecomment-5544575866 Updated compact-key screenshots are posted below. The editor intentionally retains the full literal address; only readonly chip display is abbreviated. Origin: buzz://message?channel=3355d33a-b72a-423a-b064-a58275f9a8af&id=38b3a27e689f5a9604e273d45f4e3122fceba76081e7bcd3bbdbf439524a5a18 --------- Signed-off-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- .../messages/lib/mentionClipboard.test.mjs | 22 ++ .../features/messages/lib/mentionClipboard.ts | 9 + .../lib/normalizeMentionClipboard.test.mjs | 16 ++ .../messages/lib/normalizeMentionClipboard.ts | 10 +- .../messages/lib/timelineMentionCopy.test.mjs | 15 ++ .../messages/lib/timelineMentionCopy.ts | 7 +- .../src/shared/lib/mentionDisplay.test.mjs | 46 +++++ desktop/src/shared/lib/mentionDisplay.ts | 17 ++ desktop/src/shared/ui/markdown.test.mjs | 14 +- .../shared/ui/markdown/MarkdownMention.tsx | 23 ++- .../shared/ui/markdownMentionDisplay.test.mjs | 69 +++++++ desktop/tests/e2e/cloud-provenance.spec.ts | 7 + desktop/tests/e2e/mention-clipboard.spec.ts | 15 +- desktop/tests/e2e/mention-recipients.spec.ts | 189 +++++++++++++++++- docs/mention-editor.md | 10 +- 15 files changed, 448 insertions(+), 21 deletions(-) create mode 100644 desktop/src/shared/lib/mentionDisplay.test.mjs create mode 100644 desktop/src/shared/lib/mentionDisplay.ts create mode 100644 desktop/src/shared/ui/markdownMentionDisplay.test.mjs diff --git a/desktop/src/features/messages/lib/mentionClipboard.test.mjs b/desktop/src/features/messages/lib/mentionClipboard.test.mjs index 4d8f1830aa..39f16c9679 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 19309a9cbb..8886bf8c29 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 81ccfb4b3d..3e0563ab96 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 55472f705c..1a6f7403b2 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 2c19dffbed..4f8c05842a 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 b7c137bc2f..107360a114 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 0000000000..20e0f17eb0 --- /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 0000000000..10d15643ad --- /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 44d6b16aab..08861a4daa 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 0000000000..bf96caeee0 --- /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 d04b48cf0a..b8914b491d 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 3bc4aa2885..19cd599c08 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 6c2ebaa565..0e29ed4932 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 d9a4b54ccb..9ee6339a7e 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.