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 }) => {