mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
fix(desktop): shared npub identity foundation (canonicalNpub, PubKey gate, strict parser) (#7488)
🤖 ## Summary Identity keys in the desktop app are displayed as raw 64-character hex. A person's key shows up as something like `953d3363…` — unreadable, impossible to recognize as the same identity on another screen, and a hazard when copied by hand. Nostr (the protocol Buzz runs on) has a human-readable spelling for identity keys — the `npub1…` form — but the desktop app did not use it consistently. This is the foundation of the desktop npub changes: it adds the shared pieces every identity surface builds on, and two follow-up slices stack directly on this branch — #7489 converts the identity controls (profile, settings, allowlist, workflow key fields) and #7495 converts the everyday display surfaces (mentions, member lists, sidebar, and other name fallbacks). After this change: - The shared identity widget shows the compact npub form — `npub1j57...fjmv` — instead of a hex prefix, everywhere it renders (for example the owned-agent public-key row on a profile). Copying it puts the full npub on the clipboard. - Copy is a real interaction, verified end-to-end: both popover variants put the exact canonical npub on the actual clipboard — never the raw hex the popover also lists, never a truncation — and a portaled popover's clicks no longer steal focus from the new-DM To-field mid-copy. Pointer copy, a natural Space-then-Enter path, and inner/outer Escape are covered. - Anything that isn't a valid identity key fails neutrally: short or corrupt values — including degenerate values that technically encode to a checksum-valid npub but aren't real identity keys — show "Unavailable" with no copy button, instead of a misleading value. - Both valid npub spellings display: all-lowercase `npub1…` and all-uppercase `NPUB1…` (Bech32, npub's encoding, permits either casing) both render the same canonical lowercase npub. Mixed case is rejected by the display path as written — `canonicalNpub` and the widget don't case-normalize input — while input parsing (`parsePubkeyInput`) keeps its trim-and-lowercase normalization and accepts mixed-case npubs; both paths require the decoded payload to be exactly a 64-character identity key. - Identity-key input is strict on payload: an npub whose decoded payload isn't exactly a 64-character identity key is rejected, matching the validation the app's Rust side already applies to agent allowlists. Intentional scope boundary: only surfaces that render through the shared widget change here. Outer profile copy, settings identity cards, the respond-to allowlist, and workflow key fields still show hex — they move to npub in the controls follow-up (#7489). Nothing else changes identity representation: display names, private keys, event IDs, and the hex the app stores, sends, and matches internally are untouched; only the user-facing spelling of an identity key changes. ## Details - `desktop/src/shared/lib/pubkey.ts` — `canonicalNpub()`: strict canonical full-npub helper (64-char hex in any case, or a checksum-validated npub, returns the canonical npub; anything else returns `null`); `truncateNpub()`: the compact display form; existing exports unchanged. - `desktop/src/shared/ui/PubKey.tsx` — the shared widget's identity gate validates through `canonicalNpub`; the popover copies the npub only. - `desktop/src/shared/lib/nostrUtils.ts` — `parsePubkeyInput` rejects npubs whose payload is not exactly a 64-character identity key. - `desktop/src/features/messages/ui/NewMessageScreen.tsx` — the To-field focuses its search input only for clicks that land inside the field itself, so portaled recipient popovers keep their focus while open (a popover click previously dismissed it mid-copy). - Unit suites cover the helper, widget, and parser (including the degenerate-encode and uppercase regressions); the e2e specs that render these rows assert the npub display. ### Related issue - Fixes: N/A. Searched existing issues/PRs for npub identity display — no existing match. - Stack: #7489 is based on this branch and builds on these primitives; it does not stand alone on main. ### Testing At head `b3310c248` (base: main `44316ff72`; 12 files, +440/−39): - Focused unit suites (pubkey, PubKey, parsePubkeyInput): 20/20 green; mutation-checked — removing the decoded-length predicate fails the short/empty checksum-valid-npub assertions in `canonicalNpub` and the widget, and a wrong-identity clipboard value fails the new copy assertions. - `pnpm typecheck` and `pnpm check`: pass; full desktop unit suite 6459/6459 at this exact head. - Targeted e2e at this exact head: 8/8 across the two specs that own the clipboard flows — `agent-access-warning.spec.ts` (compact variant, agent-access owner hint) and `pubkey-display-screenshots.spec.ts` (full variant, new-DM recipient verification: pointer copy, popover surviving the copy, inner/outer Escape, Space-then-Enter). - No Rust-side or build files change in this PR, so those results are unaffected. ### Task provenance Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9` Task: buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6 --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -328,7 +328,22 @@ export function NewMessageScreen() {
|
||||
<div
|
||||
className="group/to-field flex min-h-9 min-w-0 flex-1 cursor-text flex-wrap items-center gap-1.5 py-1"
|
||||
data-testid="new-message-to-field"
|
||||
onClick={() => {
|
||||
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 });
|
||||
}}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 `<PubKey variant="full">`).
|
||||
* 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 `<PubKey variant="full">` 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);
|
||||
}
|
||||
|
||||
118
desktop/src/shared/ui/PubKey.test.mjs
Normal file
118
desktop/src/shared/ui/PubKey.test.mjs
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Widget-boundary coverage for the shared <PubKey> 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("<!doctype html><html><body></body></html>", {
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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 (
|
||||
<div className="space-y-2">
|
||||
{npub ? <CopyRow label="npub" value={npub} /> : null}
|
||||
<CopyRow label="hex" value={pubkey} />
|
||||
<CopyRow label="npub" value={npub} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<span
|
||||
className={cn("inline-flex min-w-0 items-center gap-1", className)}
|
||||
data-testid={testId}
|
||||
>
|
||||
<span className="break-all font-mono text-xs">{npub ?? pubkey}</span>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
aria-label="Copy public key"
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Copy />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-96 max-w-[90vw]">
|
||||
<PubKeyDetails pubkey={pubkey} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span className="break-all font-mono text-xs">
|
||||
{npub ?? UNAVAILABLE_KEY_LABEL}
|
||||
</span>
|
||||
{npub ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
aria-label="Copy public key"
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Copy />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-96 max-w-[90vw]">
|
||||
<PubKeyDetails npub={npub} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// An unencodable key has no key display to expand: render the neutral
|
||||
// label without a popover or copy affordance.
|
||||
if (npub === null) {
|
||||
return (
|
||||
<span className={cn("font-mono", className)} data-testid={testId}>
|
||||
{UNAVAILABLE_KEY_LABEL}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -149,7 +168,7 @@ export function PubKey({
|
||||
if (!interactive) {
|
||||
return (
|
||||
<span className={cn("font-mono", className)} data-testid={testId}>
|
||||
{truncatePubkey(pubkey)}
|
||||
{truncateNpub(pubkey)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -168,7 +187,7 @@ export function PubKey({
|
||||
onMouseLeave={handleMouseLeave}
|
||||
type="button"
|
||||
>
|
||||
{truncatePubkey(pubkey)}
|
||||
{truncateNpub(pubkey)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
@@ -178,7 +197,7 @@ export function PubKey({
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<PubKeyDetails pubkey={pubkey} />
|
||||
<PubKeyDetails npub={npub} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <PubKey> 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) {
|
||||
|
||||
@@ -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 <PubKey> 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 ({
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user