diff --git a/desktop/src/features/messages/ui/NewMessageScreen.tsx b/desktop/src/features/messages/ui/NewMessageScreen.tsx index f7192f6e45c..5c5c527df7c 100644 --- a/desktop/src/features/messages/ui/NewMessageScreen.tsx +++ b/desktop/src/features/messages/ui/NewMessageScreen.tsx @@ -328,7 +328,22 @@ export function NewMessageScreen() {
{ + onClick={(event) => { + // Portaled popovers (recipient inspection and its nested key + // copy) still bubble through React's tree to this handler, + // but their event targets are not DOM descendants of the + // field. Those clicks belong to the popover's own controls + // — they must not steal focus into the search input (which + // dismisses the popover via focus-outside) or reopen the + // picker. Only clicks physically within the recipient field + // focus its input. + const { currentTarget, target } = event; + if ( + !(target instanceof Node) || + !currentTarget.contains(target) + ) { + return; + } setIsRecipientPickerOpen(true); searchInputRef.current?.focus({ preventScroll: true }); }} diff --git a/desktop/src/shared/lib/nostrUtils.ts b/desktop/src/shared/lib/nostrUtils.ts index d98c6ee8cfb..9b6fe0fb23d 100644 --- a/desktop/src/shared/lib/nostrUtils.ts +++ b/desktop/src/shared/lib/nostrUtils.ts @@ -31,7 +31,13 @@ const HEX_PUBKEY_REGEX = /^[0-9a-f]{64}$/; * anything else (does NOT throw — intended for live form validation). * * The input is trimmed first; surrounding whitespace from copy-paste is - * tolerated. + * tolerated. It is also case-normalized before matching and decoding — + * preexisting behavior — so a hex key in any casing resolves, and a + * mixed-case npub (invalid Bech32 as written) is accepted via its + * lowercased form. The identity payload itself stays strict: it must + * decode to exactly a 64-char hex identity key, because `npubEncode` also + * encodes degenerate short payloads (even `""`), which are never valid + * identities. */ export function parsePubkeyInput(input: string): string | null { const trimmed = input.trim().toLowerCase(); @@ -41,7 +47,7 @@ export function parsePubkeyInput(input: string): string | null { if (trimmed.startsWith("npub1")) { try { const decoded = decode(trimmed); - if (decoded.type === "npub") { + if (decoded.type === "npub" && HEX_PUBKEY_REGEX.test(decoded.data)) { return decoded.data; } } catch { diff --git a/desktop/src/shared/lib/parsePubkeyInput.test.mjs b/desktop/src/shared/lib/parsePubkeyInput.test.mjs index f9aa57d87f1..b74331cd984 100644 --- a/desktop/src/shared/lib/parsePubkeyInput.test.mjs +++ b/desktop/src/shared/lib/parsePubkeyInput.test.mjs @@ -19,6 +19,16 @@ describe("parsePubkeyInput", () => { assert.equal(parsePubkeyInput(NPUB), HEX); }); + it("normalizes a mixed-case npub to its canonical hex", () => { + // Preexisting behavior: user input is lowercased before decoding, so a + // mixed-case npub — invalid Bech32 as written — still resolves to the + // identity. canonicalNpub is the strict counterpart (see ../lib/pubkey.ts). + assert.equal( + parsePubkeyInput(`${NPUB.slice(0, 10)}${NPUB.slice(10).toUpperCase()}`), + HEX, + ); + }); + it("tolerates surrounding whitespace from copy-paste", () => { assert.equal(parsePubkeyInput(` ${NPUB}\n`), HEX); assert.equal(parsePubkeyInput(` ${HEX} `), HEX); @@ -42,6 +52,13 @@ describe("parsePubkeyInput", () => { assert.equal(parsePubkeyInput(`${HEX}0`), null); }); + it("rejects degenerate npubs whose payload is not a 64-char identity", () => { + // `npubEncode` happily encodes short payloads with valid checksums — + // those are not identity keys and must never bind as one. + assert.equal(parsePubkeyInput("npub1m6kmamcvty5gd"), null); + assert.equal(parsePubkeyInput("npub106246s"), null); + }); + it("rejects non-hex non-npub input", () => { assert.equal(parsePubkeyInput(""), null); assert.equal(parsePubkeyInput("alice"), null); diff --git a/desktop/src/shared/lib/pubkey.test.mjs b/desktop/src/shared/lib/pubkey.test.mjs index 76d0a29b231..bce5834162d 100644 --- a/desktop/src/shared/lib/pubkey.test.mjs +++ b/desktop/src/shared/lib/pubkey.test.mjs @@ -1,10 +1,20 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { normalizePubkey, truncatePubkey } from "./pubkey.ts"; +import { + canonicalNpub, + normalizePubkey, + truncateNpub, + truncatePubkey, +} from "./pubkey.ts"; const PUBKEY = "44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435"; +const PUBKEY_NPUB = + "npub1gjuws2a2dc8z2nszprtg7v6u9q7ffeah3hgl5yx45jwn7y7aqs6s5e9xj6"; +const HEX = "ea9b4d7a7a78a3e3729e5568b14d764d4962be0e1f20f749bcf8d9dbbf9a9328"; +const HEX_NPUB = + "npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60"; test("truncates to the canonical 8+4 form with unicode ellipsis", () => { assert.equal(truncatePubkey(PUBKEY), "44b8e82b…0435"); @@ -18,3 +28,60 @@ test("returns short strings unchanged", () => { test("normalizePubkey trims and lowercases", () => { assert.equal(normalizePubkey(" ABCDEF "), "abcdef"); }); + +test("truncateNpub compacts the hex pubkey's npub, not its hex form", () => { + assert.equal(truncateNpub(PUBKEY), "npub1gju…9xj6"); + assert.equal(truncateNpub(HEX), "npub1a2d…yp60"); + assert.equal(truncateNpub(HEX.toUpperCase()), "npub1a2d…yp60"); +}); + +test("truncateNpub accepts already-npub strings", () => { + assert.equal(truncateNpub(PUBKEY_NPUB), "npub1gju…9xj6"); + assert.equal(truncateNpub(` ${HEX_NPUB} `), "npub1a2d…yp60"); + // All-uppercase Bech32 is a valid identity per the parser; render the + // canonical form, never the neutral label. + assert.equal(truncateNpub(HEX_NPUB.toUpperCase()), "npub1a2d…yp60"); +}); + +test("truncateNpub renders the neutral label for invalid identities", () => { + // Never the raw hex/input fallback: a wrong-length or non-hex string is not + // a displayable identity. + assert.equal(truncateNpub(""), "Unavailable"); + assert.equal(truncateNpub("not a pubkey"), "Unavailable"); + assert.equal(truncateNpub(`${HEX.slice(0, 63)}`), "Unavailable"); + assert.equal(truncateNpub(`z${HEX.slice(1)}`), "Unavailable"); + // Corrupted npub checksum is not a valid identity either. + assert.equal(truncateNpub(`${HEX_NPUB.slice(0, -1)}q`), "Unavailable"); + // Other bech32 entities are not pubkeys. + assert.equal( + truncateNpub( + "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + ), + "Unavailable", + ); +}); + +test("canonicalNpub returns the full npub for valid identities only", () => { + assert.equal(canonicalNpub(HEX), HEX_NPUB); + assert.equal(canonicalNpub(HEX.toUpperCase()), HEX_NPUB); + assert.equal(canonicalNpub(HEX_NPUB), HEX_NPUB); + // All-uppercase Bech32 is valid and returns the canonical lowercase npub + // (parser agreement); a mixed-case npub is invalid Bech32. + assert.equal(canonicalNpub(HEX_NPUB.toUpperCase()), HEX_NPUB); + assert.equal( + canonicalNpub( + `${HEX_NPUB.slice(0, 10)}${HEX_NPUB.slice(10).toUpperCase()}`, + ), + null, + ); + // Strict identity keys only — short/degenerate payloads never encode. + assert.equal(canonicalNpub(""), null); + assert.equal(canonicalNpub("deadbeef"), null); + assert.equal(canonicalNpub(`${HEX.slice(0, 63)}`), null); + // Checksum-valid short npubs are degenerate payloads too (8-char and + // empty) — `npubEncode` would happily re-encode them, so never bind them. + assert.equal(canonicalNpub("npub1m6kmamcvty5gd"), null); + assert.equal(canonicalNpub("npub106246s"), null); + // Corrupted checksum never binds as the identity it resembles. + assert.equal(canonicalNpub(`${HEX_NPUB.slice(0, -2)}qq`), null); +}); diff --git a/desktop/src/shared/lib/pubkey.ts b/desktop/src/shared/lib/pubkey.ts index 6dcc48749a3..199ad52d3c1 100644 --- a/desktop/src/shared/lib/pubkey.ts +++ b/desktop/src/shared/lib/pubkey.ts @@ -1,3 +1,7 @@ +import { decode, npubEncode } from "nostr-tools/nip19"; + +import { safeNpub } from "./nostrUtils"; + /** * Canonical pubkey normalisation. * @@ -8,14 +12,22 @@ export function normalizePubkey(pubkey: string): string { return pubkey.trim().toLowerCase(); } +/** Neutral identity label for keys that cannot be encoded for display. */ +export const UNAVAILABLE_KEY_LABEL = "Unavailable"; + +const HEX_64_REGEX = /^[0-9a-f]{64}$/; + /** - * The ONE canonical compact display form for a pubkey: `abcd1234…wxyz`. + * The ONE canonical compact display form for a hex string: `abcd1234…wxyz`. * * A truncated pubkey is a recognition aid, never an identity proof — vanity * grinders forge short prefixes cheaply. Surfaces where the user makes a * trust decision must show the full npub (see ``). * Do not hand-roll `pubkey.slice(…)` display forms; `check-pubkey-truncation` * fails the build if one sneaks in outside this module. + * + * Identity (pubkey) surfaces should use `truncateNpub` instead; this hex form + * remains canonical for non-identity identifiers — event and blob IDs. */ export function truncatePubkey(pubkey: string): string { if (pubkey.length <= 12) { @@ -23,3 +35,51 @@ export function truncatePubkey(pubkey: string): string { } return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`; } + +/** + * Canonical full npub for an identity key: a 64-char hex pubkey (any + * case) or an already-npub string (checksum-validated) returns the + * canonical npub; anything else returns null. Strict 64-char identity keys + * only — `npubEncode` happily encodes short/degenerate payloads (even `""`), + * which are not displayable identities. + * + * Bech32 casing is strict on the input as written: a lowercase `npub1…` + * or an all-uppercase `NPUB1…` (both valid Bech32) returns the canonical + * lowercase npub, while a mixed-case npub is invalid Bech32 and returns + * null — `decode` enforces the all-lower/all-upper rule. This is + * intentionally stricter than the parser (`parsePubkeyInput`), which + * normalizes user input before decoding and so also accepts mixed-case + * npubs; the two agree that the payload must be a 64-hex identity key and + * that both valid casings above are acceptable input. + */ +export function canonicalNpub(pubkey: string): string | null { + const trimmed = pubkey.trim(); + if (trimmed.startsWith("npub1") || trimmed.startsWith("NPUB1")) { + try { + const decoded = decode(trimmed); + if (decoded.type !== "npub" || !HEX_64_REGEX.test(decoded.data)) { + return null; + } + return npubEncode(decoded.data); + } catch { + return null; + } + } + const normalized = normalizePubkey(trimmed); + return HEX_64_REGEX.test(normalized) ? safeNpub(normalized) : null; +} + +/** + * The ONE canonical compact identity display for a pubkey: `npub1abcd…wxyz` + * (first 8 + last 4 of the FULL npub). + * + * Identity surfaces render this form so a displayed prefix is always npub- + * shaped; the underlying hex never leaks as the identity display. A + * truncated key is a recognition aid, never an identity proof — trust + * decisions use `` or the full npub directly. Invalid + * keys render `UNAVAILABLE_KEY_LABEL`, never raw hex or raw input. + */ +export function truncateNpub(pubkey: string): string { + const npub = canonicalNpub(pubkey); + return npub === null ? UNAVAILABLE_KEY_LABEL : truncatePubkey(npub); +} diff --git a/desktop/src/shared/ui/PubKey.test.mjs b/desktop/src/shared/ui/PubKey.test.mjs new file mode 100644 index 00000000000..08167ae1c47 --- /dev/null +++ b/desktop/src/shared/ui/PubKey.test.mjs @@ -0,0 +1,118 @@ +/** + * Widget-boundary coverage for the shared identity gate. + * + * Codec vectors and the exact compact/neutral strings live in + * ../lib/pubkey.test.mjs. This suite pins what static rendering shows: the + * rendered text per variant, and that an unencodable identity — including + * degenerate-length hex and short-payload npubs whose npubEncode outputs + * carry valid checksums — renders the neutral label with no copy affordance, + * never a fake npub. The clipboard write behind the copy affordance and the + * popover the widget opens are real-bridge interactions owned by the E2E + * regressions: the full variant's copy is pinned by the new-DM recipient + * verification flow (tests/e2e/pubkey-display-screenshots.spec.ts) and the + * compact variant's by the agent-access owner hint + * (tests/e2e/agent-access-warning.spec.ts); both drive CopyRow through the + * mock bridge into the actual browser clipboard. + */ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + Node: dom.window.Node, + ResizeObserver: class { + disconnect() {} + observe() {} + unobserve() {} + }, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +const HEX = "ea9b4d7a7a78a3e3729e5568b14d764d4962be0e1f20f749bcf8d9dbbf9a9328"; +const NPUB = "npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60"; +const COMPACT_NPUB = "npub1a2d…yp60"; + +async function renderPubKey(props) { + const React = await import("react"); + const { render, within } = await import("@testing-library/react"); + const { PubKey } = await import("./PubKey.tsx"); + const view = render(React.createElement(PubKey, props)); + // render()'s bound queries search the whole body; scope to this render so + // earlier mounts (cleaned up only per test) stay invisible. + return { ...within(view.container), container: view.container }; +} + +test("compact PubKey renders the truncated npub, never the hex", async () => { + const trigger = await renderPubKey({ pubkey: HEX }); + assert.equal( + trigger.getByRole("button", { name: "Show full public key" }).textContent, + COMPACT_NPUB, + ); + assert.equal(trigger.queryByText(HEX), null); + + // A parent row that owns the interaction gets the same text, not a button. + const text = await renderPubKey({ interactive: false, pubkey: HEX }); + assert.equal(text.getByText(COMPACT_NPUB).tagName, "SPAN"); + assert.equal(text.queryByRole("button"), null); + assert.equal(text.queryByText(HEX), null); + + // An all-uppercase Bech32 npub is a valid identity (parsePubkeyInput + // accepts it); the gate must render its canonical compact form, not the + // neutral label. + const upper = await renderPubKey({ pubkey: NPUB.toUpperCase() }); + assert.equal( + upper.getByRole("button", { name: "Show full public key" }).textContent, + COMPACT_NPUB, + ); + assert.equal(upper.queryByText("Unavailable"), null); +}); + +test("full PubKey renders the complete npub with a copy affordance", async () => { + const view = await renderPubKey({ pubkey: HEX, variant: "full" }); + assert.equal(view.getByText(NPUB).textContent, NPUB); + assert.equal( + view.getByRole("button", { name: "Copy public key" }).tagName, + "BUTTON", + ); + assert.equal(view.queryByText(HEX), null); +}); + +test("unencodable keys render Unavailable with no copy affordance", async () => { + // "zz" cannot decode; "deadbeef" is a degenerate-length hex that npubEncode + // would happily turn into a checksum-valid fake npub; npub1m6kmamcvty5gd + // and npub106246s decode fine but are checksum-valid short-payload npubs + // (8-char and empty identity payloads). All four would masquerade as + // displayable identities — the gate refuses every one. + for (const pubkey of [ + "zz", + "deadbeef", + "npub1m6kmamcvty5gd", + "npub106246s", + ]) { + for (const variant of [undefined, "full"]) { + const view = await renderPubKey({ pubkey, variant }); + const label = `${pubkey} ${variant ?? "compact"}`; + assert.equal(view.getByText("Unavailable").tagName, "SPAN", label); + assert.equal(view.queryByRole("button"), null, label); + assert.equal(view.container.textContent?.includes("npub1"), false, label); + } + } +}); diff --git a/desktop/src/shared/ui/PubKey.tsx b/desktop/src/shared/ui/PubKey.tsx index fb5ac15a441..e8fe0bdead8 100644 --- a/desktop/src/shared/ui/PubKey.tsx +++ b/desktop/src/shared/ui/PubKey.tsx @@ -3,8 +3,11 @@ import * as React from "react"; import { copyTextToClipboard } from "@/shared/lib/clipboard"; import { cn } from "@/shared/lib/cn"; -import { safeNpub } from "@/shared/lib/nostrUtils"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { + canonicalNpub, + truncateNpub, + UNAVAILABLE_KEY_LABEL, +} from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, @@ -19,12 +22,12 @@ type PubKeyProps = { /** 64-char hex pubkey. */ pubkey: string; /** - * `compact` — truncated hex, click/tap opens a popover with the full npub, - * full hex, and copy buttons. The default for identity display in lists, - * cards, and metadata rows. + * `compact` — truncated npub (`npub1abcd…wxyz`), click/tap opens a popover + * with the full npub and its copy button. The default for identity display + * in lists, cards, and metadata rows. * - * `full` — the complete npub rendered inline with copy buttons. Required on - * security-decision surfaces (invite/approve, removal, trust/pairing, new + * `full` — the complete npub rendered inline with a copy button. Required + * on security-decision surfaces (invite/approve, removal, trust/pairing, new * DM, key import): a truncated key is forgeable by vanity grinding, so * decisions must be made against the whole key. */ @@ -66,12 +69,10 @@ function CopyRow({ label, value }: { label: string; value: string }) { ); } -function PubKeyDetails({ pubkey }: { pubkey: string }) { - const npub = safeNpub(pubkey); +function PubKeyDetails({ npub }: { npub: string }) { return (
- {npub ? : null} - +
); } @@ -119,29 +120,47 @@ export function PubKey({ React.useEffect(() => clearHoverTimer, [clearHoverTimer]); + // Strict identity gate: `safeNpub` would happily encode degenerate short + // payloads (e.g. an 8-char hex) as a fake npub, so the widget validates + // through `canonicalNpub` and renders Unavailable for anything else. + const npub = canonicalNpub(pubkey); + if (variant === "full") { - const npub = safeNpub(pubkey); return ( - {npub ?? pubkey} - - - - - - - - + + {npub ?? UNAVAILABLE_KEY_LABEL} + + {npub ? ( + + + + + + + + + ) : null} + + ); + } + + // An unencodable key has no key display to expand: render the neutral + // label without a popover or copy affordance. + if (npub === null) { + return ( + + {UNAVAILABLE_KEY_LABEL} ); } @@ -149,7 +168,7 @@ export function PubKey({ if (!interactive) { return ( - {truncatePubkey(pubkey)} + {truncateNpub(pubkey)} ); } @@ -168,7 +187,7 @@ export function PubKey({ onMouseLeave={handleMouseLeave} type="button" > - {truncatePubkey(pubkey)} + {truncateNpub(pubkey)} event.preventDefault()} > - + ); diff --git a/desktop/tests/e2e/agent-access-warning.spec.ts b/desktop/tests/e2e/agent-access-warning.spec.ts index adb9c6d58ab..55d5c4a0075 100644 --- a/desktop/tests/e2e/agent-access-warning.spec.ts +++ b/desktop/tests/e2e/agent-access-warning.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { npubEncode } from "nostr-tools/nip19"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -123,6 +124,31 @@ test("open agent access explains the available access before save", async ({ .getByRole("dialog", { name: "Manage agent access" }) .screenshot({ path: `${SHOTS}/selected-people-warning.png` }); + // Compact-variant clipboard regression (D1a): the owner hint's compact + // PubKey must expand to and copy the viewer's complete canonical npub — + // the truncated trigger is only a recognition aid. The real bridge writes + // the browser clipboard and the poll reads it back. + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await page + .getByTestId("agent-respond-to") + .getByRole("button", { name: "Show full public key" }) + .click(); + const copyNpubButton = page.getByRole("button", { name: "Copy npub" }); + await copyNpubButton.click(); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(npubEncode("deadbeef".repeat(8))); + await expect( + page.locator("[data-sonner-toast]").filter({ hasText: "npub copied" }), + ).toBeVisible(); + // Dismiss just the key popover: the access dialog stays open for the + // remaining mode assertions below. + await page.keyboard.press("Escape"); + await expect(copyNpubButton).toHaveCount(0); + await expect( + page.getByRole("dialog", { name: "Manage agent access" }), + ).toBeVisible(); + // Only me shares nothing, so the warning goes away entirely. await accessSelect.selectOption("owner-only"); await expect(warning).toHaveCount(0); diff --git a/desktop/tests/e2e/identity-archive.spec.ts b/desktop/tests/e2e/identity-archive.spec.ts index dfbe2ff2690..cb6e83def1b 100644 --- a/desktop/tests/e2e/identity-archive.spec.ts +++ b/desktop/tests/e2e/identity-archive.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { npubEncode } from "nostr-tools/nip19"; import { installMockBridge } from "../helpers/bridge"; @@ -33,7 +34,9 @@ async function openAliceProfile(page: import("@playwright/test").Page) { await aliceMessage.locator("button", { hasText: "alice" }).first().click(); const panel = page.getByTestId("user-profile-panel"); await expect(panel).toBeVisible(); - await expect(panel).toContainText(ALICE_PUBKEY.slice(0, 8)); + // The panel's public key row renders through the shared widget, + // which displays the canonical npub form — assert the npub prefix. + await expect(panel).toContainText(npubEncode(ALICE_PUBKEY).slice(0, 8)); } async function openProfileSettingsMenu(page: import("@playwright/test").Page) { diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 6d9baa1e23b..e9248509fd1 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { npubEncode } from "nostr-tools/nip19"; import { waitForAnimations } from "../helpers/animations"; @@ -4534,7 +4535,9 @@ test("clicking author name opens user profile panel", async ({ page }) => { // Click now opens the full profile panel instead of the popover const panel = page.getByTestId("user-profile-panel"); await expect(panel).toBeVisible(); - await expect(panel).toContainText("deadbeef"); + // The panel's public key row renders through the shared widget, + // which displays the canonical npub form — assert the npub prefix. + await expect(panel).toContainText(npubEncode(MOCK_VIEWER_PUBKEY).slice(0, 8)); }); test("hovering avatar opens popover, clicking opens profile panel", async ({ diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 095f0fe401e..e47d75706cb 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page } from "@playwright/test"; +import { npubEncode } from "nostr-tools/nip19"; import { createMockAgentMemoryListing, @@ -420,7 +421,7 @@ test("owned agent profile stays in parity between Agents and its DM", async ({ .getByRole("button", { name: `Open profile for ${agentName}` }) .click(); await expect(page.getByTestId("user-profile-public-key")).toContainText( - agentPubkey.slice(0, 8), + npubEncode(agentPubkey).slice(0, 8), ); const dmSurface = await readOwnedAgentProfileContract(page); diff --git a/desktop/tests/e2e/pubkey-display-screenshots.spec.ts b/desktop/tests/e2e/pubkey-display-screenshots.spec.ts index 77630467da1..81ff86c3299 100644 --- a/desktop/tests/e2e/pubkey-display-screenshots.spec.ts +++ b/desktop/tests/e2e/pubkey-display-screenshots.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { npubEncode } from "nostr-tools/nip19"; import { installMockBridge, @@ -189,13 +190,68 @@ test("selected new-DM recipient can be verified again through search", async ({ await charlieNameTrigger.click(); await expect(charlieKeyPopover).toBeVisible(); await expect(charliePubkey).toContainText("npub1"); + // The shared PubKey widget is npub-only — no hex text or hex copy row in + // the widget itself. (D1a boundary: the chip's legacy raw-hex popover line + // is removed with the chip change in the descendant slice.) + await expect(charliePubkey).not.toContainText(TEST_IDENTITIES.charlie.pubkey); await expect(charlieKeyPopover).toContainText(TEST_IDENTITIES.charlie.pubkey); await waitForAnimations(page); await page.getByTestId("new-message-page").screenshot({ path: `${SHOTS}/new-dm-selected-recipient-key.png`, }); + + // Full-variant clipboard regression (D1a): the nested copy affordances + // must write the recipient's complete canonical npub through the real + // bridge — never the legacy raw hex the popover also shows, and never a + // truncation. The evidence screenshot above is captured first, so this + // interaction leaves it untouched. + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + const copyPublicKeyTrigger = charliePubkey.getByRole("button", { + name: "Copy public key", + }); + await copyPublicKeyTrigger.click(); + const copyNpubButton = page.getByRole("button", { name: "Copy npub" }); + await copyNpubButton.click(); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(npubEncode(TEST_IDENTITIES.charlie.pubkey)); + await expect( + page.locator("[data-sonner-toast]").filter({ hasText: "npub copied" }), + ).toBeVisible(); + // Copying is not a dismissal: the nested affordance and the inspection + // popover it lives in both survive the copy. + await expect(copyNpubButton).toBeVisible(); + await expect(charlieKeyPopover).toBeVisible(); + + // The inner Escape closes only the nested key popover — the inspection + // stays open. + await page.keyboard.press("Escape"); + await expect(copyNpubButton).toHaveCount(0); + await expect(charlieKeyPopover).toBeVisible(); + + // Keyboard path: after the inner Escape focus returns naturally to the + // full-key trigger; Space reopens the popover, whose auto-focus lands on + // Copy npub, and Enter activates it. The sentinel proves this keyboard + // copy rewrites the clipboard rather than inheriting the value above. + await expect(copyPublicKeyTrigger).toBeFocused(); + await page.evaluate(() => + navigator.clipboard.writeText("keyboard-copy-sentinel"), + ); + await page.keyboard.press("Space"); + await expect(copyNpubButton).toBeVisible(); + await expect(copyNpubButton).toBeFocused(); + await page.keyboard.press("Enter"); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(npubEncode(TEST_IDENTITIES.charlie.pubkey)); + + // Close the reopened nested popover so the inspection popover owns the + // final Escape; the recipient itself survives both dismissals. + await page.keyboard.press("Escape"); + await expect(copyNpubButton).toHaveCount(0); await page.keyboard.press("Escape"); await expect(charlieKeyPopover).toHaveCount(0); + await expect(charlieChip).toBeVisible(); await search.fill("charlie"); await expect(charlieResult).toBeVisible(); @@ -226,6 +282,16 @@ test("selected new-DM recipient can be verified again through search", async ({ await page.getByTestId("new-message-page").screenshot({ path: `${SHOTS}/new-dm-selected-recipient.png`, }); + + // The To-field guard ignores popover clicks that bubble into the field; + // a click on the label itself — a physical descendant of the field — must + // still focus the input and open the recipient picker. + await page + .getByTestId("new-message-to-field") + .getByText("To:", { exact: true }) + .click(); + await expect(search).toBeFocused(); + await expect(page.getByTestId("new-message-recipient-popover")).toBeVisible(); }); test("member removal confirm shows the full npub inline", async ({ page }) => {