mirror of
https://github.com/block/buzz.git
synced 2026-10-30 14:01:37 +00:00
🤖 ## 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>
144 lines
5.3 KiB
TypeScript
144 lines
5.3 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import { npubEncode } from "nostr-tools/nip19";
|
|
|
|
import { installMockBridge } from "../helpers/bridge";
|
|
|
|
// NIP-IA archive button + "Archived" flair gate matrix.
|
|
//
|
|
// Guards the composition `canArchive = isSelf || isRelayAdminOrOwner ||
|
|
// isOaOwnerOfViewee` in UserProfilePanel.tsx. Unit tests cover each input in
|
|
// isolation; this spec covers the OR composition where silent regressions
|
|
// (refactor turns OR into AND, role expansion bypasses a branch, etc.) would
|
|
// otherwise slip past code review.
|
|
|
|
const ALICE_PUBKEY =
|
|
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f";
|
|
|
|
async function openSelfProfile(page: import("@playwright/test").Page) {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
// First seed message in #general is from the active identity.
|
|
const firstMessage = page.getByTestId("message-row").first();
|
|
await firstMessage.locator("button", { hasText: "npub1mock..." }).click();
|
|
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
|
|
}
|
|
|
|
async function openAliceProfile(page: import("@playwright/test").Page) {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
// Second seed message in #general is from Alice. Her display name "alice"
|
|
// is registered in mockDisplayNames, so the author button text is "alice".
|
|
const aliceMessage = page.getByTestId("message-row").nth(1);
|
|
await aliceMessage.locator("button", { hasText: "alice" }).first().click();
|
|
const panel = page.getByTestId("user-profile-panel");
|
|
await expect(panel).toBeVisible();
|
|
// 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) {
|
|
const trigger = page.getByTestId("user-profile-settings-menu-trigger");
|
|
await expect(trigger).toBeVisible();
|
|
await trigger.click();
|
|
}
|
|
|
|
test.describe("NIP-IA archive button gate", () => {
|
|
test("case 1 — self viewer + self target: Archive visible, no flair", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, { relayRole: null, oaOwnerIsMe: false });
|
|
await openSelfProfile(page);
|
|
await openProfileSettingsMenu(page);
|
|
const archiveButton = page.getByTestId("user-profile-archive-identity");
|
|
await expect(archiveButton).toBeVisible();
|
|
await expect(page.getByTestId("user-profile-archived-flair")).toHaveCount(
|
|
0,
|
|
);
|
|
|
|
// Archive is now gated behind a confirmation modal — clicking the button
|
|
// opens the dialog rather than firing immediately. Drive the full flow so
|
|
// the gate stays meaningful: the modal must surface, then confirm fires.
|
|
await expect(page.getByTestId("archive-confirm-dialog")).toHaveCount(0);
|
|
await archiveButton.click();
|
|
await expect(page.getByTestId("archive-confirm-dialog")).toBeVisible();
|
|
const confirm = page.getByTestId("archive-confirm-action");
|
|
await expect(confirm).toBeVisible();
|
|
await confirm.click();
|
|
await expect(page.getByTestId("archive-confirm-dialog")).toHaveCount(0);
|
|
});
|
|
|
|
test("case 2 — relay admin viewing Alice: Archive visible", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
relayRole: "admin",
|
|
relayRequiresMembership: true,
|
|
oaOwnerIsMe: false,
|
|
archivedIdentities: [],
|
|
});
|
|
await openAliceProfile(page);
|
|
await expect(
|
|
page.getByTestId("user-profile-archive-agent-row"),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("case 3 — verified OA owner viewing Alice: Archive visible", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
relayRole: null,
|
|
oaOwnerIsMe: true,
|
|
archivedIdentities: [],
|
|
});
|
|
await openAliceProfile(page);
|
|
await expect(
|
|
page.getByTestId("user-profile-archive-agent-row"),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("case 4 — no authority viewing Alice: Archive hidden", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
relayRole: null,
|
|
oaOwnerIsMe: false,
|
|
archivedIdentities: [],
|
|
});
|
|
await openAliceProfile(page);
|
|
await expect(
|
|
page.getByTestId("user-profile-settings-menu-trigger"),
|
|
).toHaveCount(0);
|
|
await expect(page.getByTestId("user-profile-archive-identity")).toHaveCount(
|
|
0,
|
|
);
|
|
await expect(
|
|
page.getByTestId("user-profile-unarchive-identity"),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
page.getByTestId("user-profile-archive-agent-row"),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test("case 5 — Alice archived: flair + Unarchive button (under admin gate)", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page, {
|
|
relayRole: "admin",
|
|
relayRequiresMembership: true,
|
|
oaOwnerIsMe: false,
|
|
archivedIdentities: [ALICE_PUBKEY],
|
|
});
|
|
await openAliceProfile(page);
|
|
await expect(page.getByTestId("user-profile-archived-flair")).toBeVisible();
|
|
await expect(
|
|
page.getByTestId("user-profile-unarchive-agent-row"),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByTestId("user-profile-archive-agent-row"),
|
|
).toHaveCount(0);
|
|
});
|
|
});
|