diff --git a/src/features/identity/BackupOffer.tsx b/src/features/identity/BackupOffer.tsx new file mode 100644 index 000000000..4d84adb58 --- /dev/null +++ b/src/features/identity/BackupOffer.tsx @@ -0,0 +1,41 @@ +import { Button } from "../../shared/design-system/ui/Button"; + +/** + * Pause after an imported key, on either lane. Importing must never upload + * silently: the backup is offered, and the key continues either way. The key + * may be used elsewhere, so the copy discloses that Block can restore it there + * too; choosing Back it up is the backup consent. + */ +export function BackupOffer({ + onSkip, + onBackUp, +}: { + onSkip: () => void; + onBackUp: () => void; +}) { + return ( + <> +
+ Buzz can keep an encrypted copy in your account so you can skip this + step on your next device. Block can use that copy to restore this key, + including anywhere else you use it. +
+` page or the `buzz://join`
+ * link it opens — with its code. A bare address never resolves an alias.
+ */
+export function enteredCommunity(url: string) {
+ try {
+ return { origin: enteredRelayOrigin(url), code: "" };
+ } catch {
+ const invite = parsePastedInvite(url);
+ return invite?.code
+ ? { origin: invite.community, code: invite.code }
+ : undefined;
+ }
+}
+
+/**
+ * The community URL field with its Continue button, shared by the first-run
+ * landing and Join a community. Enter is handled by the input, not a native
+ * form submit, so it reports an empty or invalid address whatever the button's
+ * state; Continue therefore stays enabled (only `disabled` turns it off) and a
+ * click shows the same error under the field. Continue only starts the lookup;
+ * the join itself happens on Join Community.
+ */
+export function CommunityUrlField({
+ url,
+ onUrlChange,
+ onSubmit,
+ disabled = false,
+}: {
+ url: string;
+ onUrlChange: (url: string) => void;
+ /** Receives the canonical secure origin, and an invite link's code ("" for an address). */
+ onSubmit: (origin: string, code: string) => void;
+ disabled?: boolean;
+}) {
+ const [invalid, setInvalid] = useState(false);
+ const errorId = useId();
+ const entered = enteredCommunity(url);
+ const submit = () => {
+ if (entered) onSubmit(entered.origin, entered.code);
+ else setInvalid(true);
+ };
+ return (
+
+
+
+
+ {
+ onUrlChange(event.target.value);
+ setInvalid(false);
+ }}
+ onKeyDown={(event) => {
+ if (event.key !== "Enter" || event.nativeEvent.isComposing)
+ return;
+ event.preventDefault();
+ submit();
+ }}
+ />
+
+
+
+
+ {invalid && (
+
+ Please enter a valid community URL
+
+ )}
+
+ );
+}
diff --git a/src/features/identity/ConnectChoice.tsx b/src/features/identity/ConnectChoice.tsx
new file mode 100644
index 000000000..67756de91
--- /dev/null
+++ b/src/features/identity/ConnectChoice.tsx
@@ -0,0 +1,59 @@
+import { Button } from "../../shared/design-system/ui/Button";
+
+/**
+ * Pause on a found community before any sign-in: the relay cannot sign the
+ * user in, but Buzz still needs a key. Connect runs
+ * the build portal's sign-in exactly as the landing's button does (the key it
+ * creates is linked to that account and backed up there); the import brings a
+ * key with no account to link it to. Either way setup ends on Join Community
+ * for the found address.
+ */
+export function ConnectChoice({
+ host,
+ onConnect,
+ onImport,
+ onBack,
+}: {
+ /** The found community's host, as the find page shows it. */
+ host: string;
+ /** Sign in at the build portal and continue as the landing’s Sign in with email does. */
+ onConnect: () => void;
+ /** The user already has a key: open the import page. */
+ onImport: () => void;
+ /** Return to the landing with the address kept. */
+ onBack: () => void;
+}) {
+ return (
+ <>
+
+
+ Connect to your community
+
+ {/* The host stands apart from the title so a long one wraps on its own
+ line and the title reads as one phrase to assistive technology. */}
+
+ {host}
+
+
+ Buzz relies on a key for your identity.
+
+
+
+
+ {/* The secondary choice is deliberately smaller than the primary. */}
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/features/identity/InviteCodeField.tsx b/src/features/identity/InviteCodeField.tsx
new file mode 100644
index 000000000..57bcce152
--- /dev/null
+++ b/src/features/identity/InviteCodeField.tsx
@@ -0,0 +1,73 @@
+import { useId, useState } from "react";
+import { Button } from "../../shared/design-system/ui/Button";
+import { Field } from "../../shared/design-system/ui/Field";
+import { Input } from "../../shared/design-system/ui/Input";
+
+/**
+ * The invite page's code field with its Join button. A refusal shows under
+ * the field until the code is edited; the typed code is kept either way.
+ */
+export function InviteCodeField({
+ error,
+ busy,
+ pending,
+ onSubmit,
+}: {
+ /** Why the relay refused the last code. */
+ error?: string | undefined;
+ /** Something on the page is in flight: nothing can be submitted. */
+ busy: boolean;
+ /** This field's own claim is the one in flight. */
+ pending: boolean;
+ onSubmit: (code: string) => void;
+}) {
+ const [code, setCode] = useState("");
+ const [edited, setEdited] = useState(false);
+ const errorId = useId();
+ const shown = edited ? undefined : error;
+ const value = code.trim();
+ const submit = () => {
+ if (!value || busy) return;
+ setEdited(false);
+ onSubmit(value);
+ };
+ // `CommunityUrlField`'s recipe: the error sits below the row, so Join stays
+ // aligned with the input.
+ return (
+
+
+
+
+ {
+ setCode(event.target.value);
+ setEdited(true);
+ }}
+ onKeyDown={(event) => {
+ if (event.key !== "Enter" || event.nativeEvent.isComposing)
+ return;
+ event.preventDefault();
+ submit();
+ }}
+ />
+
+
+
+
+ {shown && (
+
+ {shown}
+
+ )}
+
+ );
+}
diff --git a/src/features/identity/JoinCommunity.tsx b/src/features/identity/JoinCommunity.tsx
new file mode 100644
index 000000000..7a0ce6194
--- /dev/null
+++ b/src/features/identity/JoinCommunity.tsx
@@ -0,0 +1,82 @@
+import { Avatar } from "../../shared/design-system/ui/Avatar";
+import { Button } from "../../shared/design-system/ui/Button";
+import type { OwnedCommunity } from "./builderlab-account";
+import { CommunityUrlField } from "./CommunityUrlField";
+import { OnboardingScreen } from "./OnboardingScreen";
+
+/**
+ * First-run landing after setup. The community URL is the live action; a
+ * linked account's owned communities are offered as rows that take the same
+ * path with their address.
+ */
+export function JoinCommunity({
+ url,
+ onUrlChange,
+ onFind,
+ owned = [],
+}: {
+ url: string;
+ onUrlChange: (url: string) => void;
+ /** Receives the canonical secure origin, and an invite link's code when one was pasted. */
+ onFind: (origin: string, code?: string) => void;
+ owned?: readonly OwnedCommunity[] | undefined;
+}) {
+ // The in-app Join a community landing's layout (CommunityLanding).
+ return (
+
+
+ Join a community
+
+
+ {owned.length > 0 && (
+ // min-w-0 keeps a long hostname truncating rather than widening the grid.
+
+
+ Communities you own
+
+
+ {owned.map(({ host, url: origin }) => (
+ -
+
+
+ {host}
+
+
+
+ ))}
+
+
+ )}
+
+
+ >
+ }
+ >
+
+
+
+ );
+}
diff --git a/src/features/identity/KeyChoice.tsx b/src/features/identity/KeyChoice.tsx
new file mode 100644
index 000000000..ea4496a96
--- /dev/null
+++ b/src/features/identity/KeyChoice.tsx
@@ -0,0 +1,75 @@
+import { Button } from "../../shared/design-system/ui/Button";
+import styles from "./IdentitySetup.module.css";
+import { OnboardingScreen } from "./OnboardingScreen";
+
+/**
+ * Pause on an unlinked account: create a key, which is then linked and backed
+ * up, or bring an existing one. The copy discloses that Block can restore the
+ * key, since choosing to create it is the backup consent.
+ */
+export function KeyChoice({
+ email,
+ backups,
+ onCreate,
+ onImport,
+ onSwitchAccount,
+}: {
+ /** The signed-in Builderlab account the lookup ran for. */
+ email: string | undefined;
+ /**
+ * The keys the backed-up-key lookup listed; `undefined` when it was
+ * unavailable or failed. Backups outlive an unlink, so an unlinked account
+ * may still list some, and only an empty list says none was found.
+ */
+ backups: readonly string[] | undefined;
+ /** Create the key, link it to the account, then back it up. */
+ onCreate: () => void;
+ /** The user already has a key: open the import page. */
+ onImport: () => void;
+ /** Wrong account: sign the session out and return to the landing. */
+ onSwitchAccount: () => void;
+}) {
+ // The welcome screen's create-or-import stack, after sign-in.
+ return (
+
+
+ Set up your key
+
+
+ Buzz signs everything you do with a private key kept on this device.
+ {backups?.length === 0 &&
+ " We didn’t find a backed-up key for this account, so let’s make one."}{" "}
+ An encrypted copy is backed up to your account, and Block can use it
+ to restore the key on your other devices.
+
+ {/* Who the key would belong to, so a wrong account is visible before any key exists. */}
+
+ {email ? `Signed in as ${email}.` : "Signed in."}
+
+
+ }
+ >
+
+
+
+
+ );
+}
diff --git a/src/features/identity/KeyImport.tsx b/src/features/identity/KeyImport.tsx
new file mode 100644
index 000000000..e982545f0
--- /dev/null
+++ b/src/features/identity/KeyImport.tsx
@@ -0,0 +1,213 @@
+import { useId, useRef, useState } from "react";
+import { npubEncode } from "nostr-tools/nip19";
+import { useFileDrop } from "../messages/use-file-drop";
+import { Button } from "../../shared/design-system/ui/Button";
+import { Field } from "../../shared/design-system/ui/Field";
+import { Input } from "../../shared/design-system/ui/Input";
+import styles from "./IdentitySetup.module.css";
+import { classifyKeyImport, readKeyFile } from "./key-import";
+import { OnboardingScreen } from "./OnboardingScreen";
+import { SetupProgress } from "./SetupProgress";
+
+/**
+ * Pause for a key the user already has: paste or type an `nsec1…` key, or
+ * drop or choose a small text file holding one. Valid input previews the
+ * npub; Continue hands the nsec to native import. The key exists only in this
+ * component's state and is never logged or stored, so leaving the page
+ * discards it. While import runs the form gives way to the progress page on
+ * the same screen but stays mounted, so a native refusal returns to it with
+ * the typed key kept.
+ * With `expectedPubkey`, only that key continues: any other valid nsec is
+ * refused under the field.
+ */
+export function KeyImport({
+ expectedPubkey,
+ onBack,
+ onImport,
+}: {
+ /** The hex key linked to the signed-in account, when the lane has one. */
+ expectedPubkey?: string | undefined;
+ onBack: () => void;
+ /** Rejects with the native refusal, shown under the field. */
+ onImport: (nsec: string) => Promise;
+}) {
+ const [text, setText] = useState("");
+ const [revealed, setRevealed] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [failure, setFailure] = useState();
+ const form = useRef(null);
+ const formId = useId();
+ const picker = useRef(null);
+ const input = classifyKeyImport(text);
+ const expected =
+ expectedPubkey === undefined ? undefined : npubEncode(expectedPubkey);
+ const mismatch =
+ input.kind === "nsec" && expected !== undefined && input.npub !== expected;
+ const ready = input.kind === "nsec" && !mismatch;
+ const change = (next: string) => {
+ setText(next);
+ setFailure(undefined);
+ // Masked by default must re-assert for the next key pasted.
+ if (!next.trim()) setRevealed(false);
+ };
+ const fromFile = async (file: File | undefined) => {
+ if (!file) return;
+ try {
+ change(await readKeyFile(file));
+ } catch (error) {
+ setFailure(
+ error instanceof Error ? error.message : "Couldn’t read that file.",
+ );
+ }
+ };
+ // The whole form is the drop target; the hook rebinds when the form returns.
+ const dragging = useFileDrop(form, !busy, (files) => void fromFile(files[0]));
+ const submit = async () => {
+ if (!ready || busy) return;
+ setBusy(true);
+ try {
+ await onImport(input.nsec);
+ } catch (error) {
+ setFailure(
+ error instanceof Error ? error.message : "Couldn’t import this key.",
+ );
+ } finally {
+ setBusy(false);
+ }
+ };
+ const message =
+ failure ??
+ (mismatch ? (
+ <>
+ That isn’t the key linked to this account. It expects{" "}
+
+ {expected}
+ {" "}
+ (the key on your other device).
+ >
+ ) : input.kind === "encrypted" ? (
+ "Password-protected backups aren’t supported here yet. Paste the nsec1 key itself."
+ ) : input.kind === "invalid" ? (
+ "That doesn’t look like a valid nsec. Paste an nsec1 key."
+ ) : undefined);
+ return (
+
+
+
+ ) : (
+ <>
+ {/* The onboarding import layout: the actions below submit through `form`. */}
+
+ {/* Outside the form, so the key is its only field and Enter submits. */}
+ {
+ const file = event.target.files?.[0];
+ event.target.value = "";
+ void fromFile(file);
+ }}
+ />
+ >
+ )
+ }
+ >
+ {!busy && (
+ <>
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/features/identity/KeyLost.tsx b/src/features/identity/KeyLost.tsx
new file mode 100644
index 000000000..363e8eec3
--- /dev/null
+++ b/src/features/identity/KeyLost.tsx
@@ -0,0 +1,73 @@
+import { Button } from "../../shared/design-system/ui/Button";
+import type { OwnedCommunity } from "./builderlab-account";
+import { WireframePanel } from "./Wireframe";
+
+/**
+ * Pause for a linked key the user no longer has. Starting over creates a new
+ * key, unlinks this one, links the new one in its place and backs it up, with
+ * no key choice between; the page spells out what the old key keeps and, as
+ * Set up your key does, that Block can restore the new one.
+ */
+export function KeyLost({
+ owned,
+ onBack,
+ onStartOver,
+}: {
+ /** The communities the old key owns; `undefined` when the list could not be loaded. */
+ owned: readonly OwnedCommunity[] | undefined;
+ onBack: () => void;
+ onStartOver: () => void;
+}) {
+ return (
+ <>
+
+
+ Start over with a new key
+
+
+ A new key is a new identity. Everything the old key could reach stays
+ with it: its profile, its messages and the communities it belongs to.
+ Starting over unlinks the old key, then creates a new key, links it to
+ this account and keeps an encrypted copy that Block can use to restore
+ it.
+
+
+
+ Communities you own
+ {owned === undefined ? (
+
+ Couldn’t load the communities this account owns.
+
+ ) : owned.length === 0 ? (
+ None
+ ) : (
+
+ {owned.map(({ host, url }) => (
+ -
+ {host}
+
+ ))}
+
+ )}
+
+ Memberships in other people’s communities can’t be listed here and
+ will also be lost.
+
+
+ {/* Footer order: secondary first, primary last. */}
+
+
+
+
+ >
+ );
+}
diff --git a/src/features/identity/KeyRestore.tsx b/src/features/identity/KeyRestore.tsx
new file mode 100644
index 000000000..0e09a4904
--- /dev/null
+++ b/src/features/identity/KeyRestore.tsx
@@ -0,0 +1,98 @@
+import { useSyncExternalStore } from "react";
+import { Button } from "../../shared/design-system/ui/Button";
+import type { Recovery } from "./builderlab-account";
+import styles from "./IdentitySetup.module.css";
+import { OnboardingScreen } from "./OnboardingScreen";
+import { RestoreIdentity } from "./RestoreIdentity";
+import { recoveryOff } from "./service";
+
+/**
+ * Pause after Welcome back's Restore from backup: native recovery's browser
+ * sign-in, then the linked key's backup to install. Where recovery is
+ * unavailable or refused, which no retry changes, or no backup holds the
+ * linked key, the page offers Enter your private key instead of Try again.
+ */
+export function KeyRestore({
+ recovery,
+ expectedPubkey,
+ onBack,
+ onImport,
+ onInstalled,
+}: {
+ recovery: Recovery;
+ /** The hex key linked to the account: the only one restored. */
+ expectedPubkey: string;
+ /** Recovery was canceled: return to Welcome back. */
+ onBack: () => void;
+ /** Recovery was canceled: open the import page. */
+ onImport: () => void;
+ /** Installing settled, restored or not. */
+ onInstalled: () => void;
+}) {
+ const state = useSyncExternalStore(recovery.subscribe, recovery.snapshot);
+ // A recovery holds the identity busy, so leaving cancels it first.
+ const leave = (then: () => void) => () =>
+ recovery.cancelRecovery().then((canceled) => {
+ if (canceled) then();
+ return canceled;
+ });
+ const off = recoveryOff(state);
+ // "Stored securely" holds only while a restore may still install the key:
+ // an outcome that restored nothing explains itself below.
+ const restoring =
+ state?.status === "waiting" ||
+ (state?.status === "choosing" &&
+ state.candidates.some(
+ (candidate) => candidate.pubkey === expectedPubkey,
+ ));
+ return (
+
+
+ Restore from backup
+
+ {(off || restoring) && (
+
+ {off
+ ? "Backup restore isn’t available for this account. Enter your private key to continue."
+ : "Your key is stored securely on this device."}
+
+ )}
+ {off ? (
+ // Footer order: secondary first, primary last.
+
+
+
+
+ ) : state ? (
+
+ recovery.installRecovery(handle).then(onInstalled),
+ }}
+ recovery={state}
+ expectedPubkey={expectedPubkey}
+ onImport={() => void leave(onImport)()}
+ />
+ ) : null}
+
+ }
+ >
+ {null}
+
+ );
+}
diff --git a/src/features/identity/ProfileStep.test.tsx b/src/features/identity/ProfileStep.test.tsx
new file mode 100644
index 000000000..1192af7ff
--- /dev/null
+++ b/src/features/identity/ProfileStep.test.tsx
@@ -0,0 +1,132 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { ComponentProps } from "react";
+import { afterEach, expect, it, vi } from "vitest";
+import { ProfileStep } from "./ProfileStep";
+
+const found = {
+ url: "https://relay.example.com",
+ host: "relay.example.com",
+};
+const none = {
+ existing: {},
+ profile: { name: "", picture: "", about: "" },
+ exists: false,
+};
+function deferred() {
+ let resolve!: () => void;
+ let reject!: (reason: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+function step(props: Partial> = {}) {
+ const onContinue = vi.fn["onContinue"]>(
+ () => Promise.resolve(),
+ );
+ const onSkip = vi.fn["onSkip"]>(() =>
+ Promise.resolve(),
+ );
+ render(
+ ,
+ );
+ return { onContinue, onSkip, user: userEvent.setup() };
+}
+afterEach(() => {
+ cleanup();
+ vi.resetAllMocks();
+});
+
+it("suggests and focuses the default name, needs a name to continue, and hands the trimmed profile to Continue", async () => {
+ const { onContinue, onSkip, user } = step();
+ const name = screen.getByRole("textbox", { name: "Display name" });
+ expect(name).toHaveValue("ada");
+ expect(name).toHaveFocus();
+ await user.clear(name);
+ expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled();
+ await user.type(name, " Ada ");
+ await user.type(
+ screen.getByRole("textbox", { name: "Profile description (optional)" }),
+ "Hi ",
+ );
+ await user.click(screen.getByRole("button", { name: "Continue" }));
+ expect(onContinue).toHaveBeenCalledExactlyOnceWith({
+ name: "Ada",
+ picture: "",
+ about: "Hi",
+ });
+ expect(onSkip).not.toHaveBeenCalled();
+});
+
+it("prefills a relay profile and hands it to Continue as it is", async () => {
+ const profile = { name: "Ada", picture: "", about: "Hi" };
+ const { onContinue, user } = step({
+ read: { existing: { name: "Ada", about: "Hi" }, profile, exists: true },
+ defaultName: "ignored",
+ });
+ expect(screen.getByRole("textbox", { name: "Display name" })).toHaveValue(
+ "Ada",
+ );
+ await user.click(screen.getByRole("button", { name: "Continue" }));
+ expect(onContinue).toHaveBeenCalledExactlyOnceWith(profile);
+});
+
+it("holds both buttons while Continue runs, and a rejection stays on the step with the error inline and the draft kept", async () => {
+ const gate = deferred();
+ const { onContinue, onSkip, user } = step();
+ onContinue.mockReturnValueOnce(gate.promise);
+ const name = screen.getByRole("textbox", { name: "Display name" });
+ await user.clear(name);
+ await user.type(name, "Ada{Enter}");
+ expect(onContinue).toHaveBeenCalledOnce();
+ expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Skip for now" })).toBeDisabled();
+ expect(name).toBeDisabled();
+ gate.reject(
+ new Error(
+ "Your profile change is not current. Your edits are retained; try again.",
+ ),
+ );
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Your profile change is not current. Your edits are retained; try again.",
+ );
+ expect(name).toHaveValue("Ada");
+ expect(name).toBeEnabled();
+ expect(screen.getByRole("button", { name: "Continue" })).toBeEnabled();
+ expect(screen.getByRole("button", { name: "Skip for now" })).toBeEnabled();
+ expect(onSkip).not.toHaveBeenCalled();
+});
+
+it("classifies a denied read, and Skip for now hands off without the draft and keeps the step when it fails", async () => {
+ const { onContinue, onSkip, user } = step();
+ onContinue.mockRejectedValueOnce(
+ Object.assign(new Error("Relay read failed (403)"), {}),
+ );
+ await user.click(screen.getByRole("button", { name: "Continue" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "relay.example.com no longer admits this key. Skip for now and ask a community administrator.",
+ );
+ onSkip.mockRejectedValueOnce(
+ new Error("Could not save this community on this device. Try again."),
+ );
+ await user.click(screen.getByRole("button", { name: "Skip for now" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Could not save this community on this device. Try again.",
+ );
+ expect(onSkip).toHaveBeenCalledExactlyOnceWith();
+ expect(screen.getByRole("button", { name: "Skip for now" })).toBeEnabled();
+ await user.click(screen.getByRole("button", { name: "Skip for now" }));
+ expect(onSkip).toHaveBeenCalledTimes(2);
+ expect(screen.queryByRole("alert")).toBeNull();
+});
diff --git a/src/features/identity/ProfileStep.tsx b/src/features/identity/ProfileStep.tsx
new file mode 100644
index 000000000..ac6338e9f
--- /dev/null
+++ b/src/features/identity/ProfileStep.tsx
@@ -0,0 +1,123 @@
+import { useState } from "react";
+import type { inspectProfile } from "../communities/api";
+import type { FoundCommunity } from "../communities/find-community";
+import { canSaveProfile, ProfileFields } from "../communities/ProfileFields";
+import type { PersonalProfile } from "../communities/service";
+import { readErrorKind } from "../relay/errors";
+import { Button } from "../../shared/design-system/ui/Button";
+import { WireframePanel } from "./Wireframe";
+
+/**
+ * First-run profile, shown once the relay has admitted the key and before the
+ * gate lifts. A pure form: the caller owns what Continue and Skip do with the
+ * draft (the join's completion, which publishes the kind 0 through the shared
+ * owner and saves the membership after it), and a rejection stays here with
+ * the error inline and the draft intact.
+ */
+export function ProfileStep({
+ found,
+ read,
+ defaultName,
+ onContinue,
+ onSkip,
+}: {
+ found: Pick;
+ /** The admission read that joined the community: the prefill. */
+ read: Awaited>;
+ /** Suggested display name when the relay holds no profile for this key. */
+ defaultName: string;
+ /** Publishes the trimmed draft and finishes the join; a rejection keeps the step. */
+ onContinue: (profile: PersonalProfile) => Promise;
+ /** Finishes the join without publishing; a rejection keeps the step. */
+ onSkip: () => Promise;
+}) {
+ const [draft, setDraft] = useState(() =>
+ read.exists ? read.profile : { name: defaultName, picture: "", about: "" },
+ );
+ const [busy, setBusy] = useState(false);
+ const [uploading, setUploading] = useState(false);
+ const [error, setError] = useState("");
+ const canContinue = canSaveProfile(draft) && !busy && !uploading;
+ const run = async (action: () => Promise) => {
+ setBusy(true);
+ setError("");
+ try {
+ await action();
+ } catch (reason) {
+ setError(publishFailure(found.host, reason));
+ setBusy(false);
+ }
+ };
+ return (
+
+ );
+}
+
+/** Classified like the join's read failure; the owner's own messages (the
+ * native route's "Community request failed (500)", an unconfirmed receipt, a
+ * readback that is not current, a device record that will not save) are
+ * shown as is. */
+function publishFailure(host: string, error: unknown) {
+ if (readErrorKind(error) === "denied")
+ return `${host} no longer admits this key. Skip for now and ask a community administrator.`;
+ return error instanceof Error
+ ? error.message
+ : "Could not reach the relay. Check your connection and try again.";
+}
diff --git a/src/features/identity/PublicKeyPanel.tsx b/src/features/identity/PublicKeyPanel.tsx
new file mode 100644
index 000000000..c31f60c0b
--- /dev/null
+++ b/src/features/identity/PublicKeyPanel.tsx
@@ -0,0 +1,55 @@
+import { useState } from "react";
+import { CopyIcon } from "../../shared/design-system/icons/index";
+import { Button } from "../../shared/design-system/ui/Button";
+import { WireframePanel } from "./Wireframe";
+
+/**
+ * A full npub — never hex or an abbreviation — with Copy and inline copy
+ * feedback, for first-run pages that render outside ToastProvider. The
+ * Profile panel's public-key recipe.
+ */
+export function PublicKeyPanel({
+ publicKey,
+ heading = "Your public key (npub)",
+}: {
+ publicKey: string;
+ heading?: string;
+}) {
+ const [copyStatus, setCopyStatus] = useState("");
+ return (
+
+
+ {heading}
+
+
+
+ {publicKey}
+
+
+ This is your public identity — it’s safe to share.
+
+
+ {copyStatus}
+
+
+ );
+}
diff --git a/src/features/identity/RestoreIdentity.tsx b/src/features/identity/RestoreIdentity.tsx
index b4b86f6c6..07281d947 100644
--- a/src/features/identity/RestoreIdentity.tsx
+++ b/src/features/identity/RestoreIdentity.tsx
@@ -6,18 +6,37 @@ import { Field } from "../../shared/design-system/ui/Field";
import { Radio, RadioGroup } from "../../shared/design-system/ui/RadioGroup";
import { type Identity, type RecoveryState, recoveryOff } from "./service";
+/**
+ * Recovered backups to restore one from. With `expectedPubkey`, only that key
+ * is offered: when no backup holds it, nothing can be restored, and `onImport`
+ * offers Enter your private key instead. Try recovery again is offered only
+ * where a retry may help.
+ */
export function RestoreIdentity({
identity,
recovery,
+ expectedPubkey,
+ onImport,
}: {
- identity: Identity;
+ identity: Pick<
+ Identity,
+ "beginRecovery" | "installRecovery" | "cancelRecovery"
+ >;
recovery: RecoveryState;
+ /** The hex key linked to the signed-in account, when only it may be restored. */
+ expectedPubkey?: string | undefined;
+ /** No backup holds `expectedPubkey`: leave recovery for the import page. */
+ onImport?: (() => void) | undefined;
}) {
const [selected, setSelected] = useState<{
recovery: RecoveryState;
handle: string;
}>();
- const candidates = recovery.status === "choosing" ? recovery.candidates : [];
+ const recovered = recovery.status === "choosing" ? recovery.candidates : [];
+ const candidates =
+ expectedPubkey === undefined
+ ? recovered
+ : recovered.filter((candidate) => candidate.pubkey === expectedPubkey);
const labels = publicKeyLabels(
candidates.map((candidate) => candidate.pubkey),
);
@@ -29,6 +48,7 @@ export function RestoreIdentity({
: undefined;
const handle =
selected?.recovery === recovery ? selected.handle : current?.handle;
+ const mismatched = recovered.length > 0 && candidates.length === 0;
return (
{recovery.status === "waiting" ? (
@@ -39,6 +59,11 @@ export function RestoreIdentity({
Canceling recovery…
) : recovery.status === "error" ? (
{recovery.error}
+ ) : mismatched ? (
+
+ None of your backups holds the key linked to this account, so nothing
+ was restored.
+
) : candidates.length === 0 ? (
{recovery.failedCount
@@ -104,8 +129,12 @@ export function RestoreIdentity({
>
Restore this identity
+ ) : mismatched && onImport ? (
+
) : (recovery.status === "error" && !recoveryOff(recovery)) ||
- (recovery.status === "choosing" && candidates.length === 0) ? (
+ (recovery.status === "choosing" && recovered.length === 0) ? (