mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
fix(desktop): restore mention chip identity icons (#7338)
## 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 <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
`<span data-mention="" data-mention-pubkey="${key}" ` +
|
||||
`data-mention-label="${label}">${text}</span>`;
|
||||
assert.equal(
|
||||
normalizeMentionClipboardContent(html("Scout (150b20bd…15dc) 2")).text,
|
||||
`@${label}`,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeMentionClipboardContent(html("Scout (150b20bd…15dc)")).text,
|
||||
"Scout (150b20bd…15dc)",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
`<span data-mention="" data-mention-pubkey="${JOHN_SMITH_PUBKEY}" ` +
|
||||
`data-mention-label="${label}" class="mention-chip">` +
|
||||
'<span class="inline-chip-leading-fragment">Scout</span> (7c7c7c7c…7c7c) 2</span>',
|
||||
);
|
||||
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("…"));
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
46
desktop/src/shared/lib/mentionDisplay.test.mjs
Normal file
46
desktop/src/shared/lib/mentionDisplay.test.mjs
Normal file
@@ -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})`,
|
||||
);
|
||||
});
|
||||
17
desktop/src/shared/lib/mentionDisplay.ts
Normal file
17
desktop/src/shared/lib/mentionDisplay.ts
Normal file
@@ -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]}`;
|
||||
}
|
||||
@@ -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"[^>]*>alice</);
|
||||
assert.match(html, />alice</);
|
||||
assert.doesNotMatch(html, />@alice</);
|
||||
});
|
||||
@@ -1432,7 +1437,12 @@ test("agent mentions retain the bot treatment instead of the human icon", () =>
|
||||
|
||||
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"[^>]*>alice</);
|
||||
assert.match(html, />alice</);
|
||||
assert.doesNotMatch(html, />@alice</);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,12 @@ import type * as React from "react";
|
||||
import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { WRAPPING_INLINE_CHIP_CLASSES } from "@/shared/ui/mentionChip";
|
||||
import { formatMentionDisplayLabel } from "@/shared/lib/mentionDisplay";
|
||||
import {
|
||||
inlineChipIconClasses,
|
||||
inlineChipLeadingEnd,
|
||||
WRAPPING_INLINE_CHIP_CLASSES,
|
||||
} from "@/shared/ui/mentionChip";
|
||||
import { InlineChip } from "@/shared/ui/InlineChip";
|
||||
import { useMarkdownRuntime } from "./runtimeContext";
|
||||
|
||||
@@ -28,6 +33,9 @@ export function createMarkdownMention(interactive: boolean) {
|
||||
pubkey !== undefined &&
|
||||
agentMentionPubkeysByName?.[mentionName] === pubkey;
|
||||
const mentionLabel = mentionText.replace(/^@/, "");
|
||||
const displayLabel = formatMentionDisplayLabel(mentionLabel, pubkey);
|
||||
const icon = isAgentMention ? "agent" : "human";
|
||||
const leadingEnd = inlineChipLeadingEnd(displayLabel);
|
||||
// Only chips that actually open a profile get the clickable affordance.
|
||||
// A mention whose pubkey didn't resolve stays a plain chip — a pointer
|
||||
// cursor there promises a click that does nothing.
|
||||
@@ -46,10 +54,19 @@ export function createMarkdownMention(interactive: boolean) {
|
||||
)}
|
||||
title={mentionLabel}
|
||||
aria-label={mentionLabel}
|
||||
icon={isAgentMention ? "agent" : "human"}
|
||||
icon={icon}
|
||||
interactive={opensProfile}
|
||||
>
|
||||
{mentionLabel}
|
||||
{/* Wrapping chips hide the outer icon; keep it with a bounded prefix. */}
|
||||
<span
|
||||
className={cn(
|
||||
"inline-chip-leading-fragment",
|
||||
inlineChipIconClasses(icon),
|
||||
)}
|
||||
>
|
||||
{displayLabel.slice(0, leadingEnd)}
|
||||
</span>
|
||||
{displayLabel.slice(leadingEnd)}
|
||||
{isAgentMention ? <AgentManagementMarker pubkey={pubkey} /> : null}
|
||||
</InlineChip>
|
||||
);
|
||||
|
||||
69
desktop/src/shared/ui/markdownMentionDisplay.test.mjs
Normal file
69
desktop/src/shared/ui/markdownMentionDisplay.test.mjs
Normal file
@@ -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=/);
|
||||
});
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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] }]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user