fix(desktop): remediate all onboarding dead paths (D1-D7) (#1856)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Will Pfleger
2026-07-14 19:31:28 -04:00
committed by GitHub
parent 7e009a8937
commit 5bb6192951
21 changed files with 2187 additions and 364 deletions
+21 -23
View File
@@ -27,6 +27,8 @@ import { useCommunityInit } from "@/features/communities/useCommunityInit";
import { useNestNotifications } from "@/features/communities/useNestNotifications";
import { useCommunities } from "@/features/communities/useCommunities";
import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup";
import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen";
import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay";
import { createBuzzQueryClient } from "@/shared/api/queryClient";
import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri";
import { listenForDeepLinks } from "@/shared/deep-link";
@@ -274,19 +276,15 @@ function CommunityQueryProvider({ children }: { children: ReactNode }) {
}
function AppReady({
canBackToCommunitySetup,
isCompletingFirstRunCommunity,
isSharedIdentity,
isCommunitySwitch,
onFirstRunCommunitySettled,
onBackToCommunitySetup,
}: {
canBackToCommunitySetup: boolean;
isCompletingFirstRunCommunity: boolean;
isSharedIdentity: boolean;
isCommunitySwitch: boolean;
onFirstRunCommunitySettled: () => void;
onBackToCommunitySetup: () => void;
}) {
const onboarding = useAppOnboardingState(isSharedIdentity);
@@ -316,11 +314,9 @@ function AppReady({
return (
<OnboardingFlow
actions={onboarding.flow.actions}
canBackToCommunitySetup={canBackToCommunitySetup}
identityLost={onboarding.identityLost}
initialProfile={onboarding.flow.initialProfile}
key={onboarding.currentPubkey ?? "anonymous"}
onBackToCommunitySetup={onBackToCommunitySetup}
/>
);
}
@@ -360,16 +356,12 @@ export function App() {
activeCommunity,
reinitKey,
addCommunity,
clearCommunities,
switchCommunity,
reconnectCommunity,
} = useCommunities();
const [isCompletingFirstRunCommunity, setIsCompletingFirstRunCommunity] =
useState(false);
const [canBackToCommunitySetup, setCanBackToCommunitySetup] = useState(false);
const [welcomeTransitionMode, setWelcomeTransitionMode] = useState<
"initial" | "backward"
>("initial");
const [isCommunityChangeOpen, setIsCommunityChangeOpen] = useState(false);
useEffect(() => {
const unlisten = listenForDeepLinks({
@@ -408,22 +400,13 @@ export function App() {
const handleSetupComplete = useCallback(
(community: Community) => {
setWelcomeTransitionMode("initial");
setIsCompletingFirstRunCommunity(true);
setCanBackToCommunitySetup(true);
const communityId = addCommunity(community);
switchCommunity(communityId);
},
[addCommunity, switchCommunity],
);
const handleBackToCommunitySetup = useCallback(() => {
setWelcomeTransitionMode("backward");
setIsCompletingFirstRunCommunity(false);
setCanBackToCommunitySetup(false);
clearCommunities();
}, [clearCommunities]);
const handleFirstRunCommunitySettled = useCallback(() => {
setIsCompletingFirstRunCommunity(false);
}, []);
@@ -442,12 +425,29 @@ export function App() {
return (
<WelcomeSetup
defaultRelayUrl={community.defaultRelayUrl}
initialTransitionMode={welcomeTransitionMode}
onComplete={handleSetupComplete}
/>
);
}
// Surface apply failures so the user can retry or change community.
if ("error" in community && community.error) {
return (
<>
<CommunityApplyErrorScreen
error={community.error}
onChangeCommunity={() => setIsCommunityChangeOpen(true)}
onRetry={reconnectCommunity}
/>
{isCommunityChangeOpen ? (
<CommunityChangeOverlay
onClose={() => setIsCommunityChangeOpen(false)}
/>
) : null}
</>
);
}
// Wait for this exact community config to be applied to the backend before
// rendering anything that connects to the relay. The appliedKey check avoids
// a one-render race where React sees the new active community while the Tauri
@@ -471,13 +471,11 @@ export function App() {
return (
<CommunityQueryProvider key={communityKey}>
<AppReady
canBackToCommunitySetup={canBackToCommunitySetup}
isCompletingFirstRunCommunity={isCompletingFirstRunCommunity}
isCommunitySwitch={isCommunitySwitch}
key={communityKey}
isSharedIdentity={sharedIdentity}
onFirstRunCommunitySettled={handleFirstRunCommunitySettled}
onBackToCommunitySetup={handleBackToCommunitySetup}
/>
{showBootSplashOverlay ? (
<div
@@ -0,0 +1,89 @@
/**
* Unit tests for relay URL normalization and probe helpers (D4).
*
* normalizeRelayUrl is pure and fully testable. probeRelayReachable requires
* a WebSocket runtime — its contract (cancel-safe, timeout-bounded, close-
* on-all-exits) is verified by the E2E onboarding specs on CI shards.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { normalizeRelayUrl } from "./relayProbe.ts";
// ---------------------------------------------------------------------------
// Valid inputs
// ---------------------------------------------------------------------------
test("normalizeRelayUrl_wss_passthrough", () => {
assert.equal(
normalizeRelayUrl("wss://relay.example.com"),
"wss://relay.example.com",
);
});
test("normalizeRelayUrl_ws_passthrough", () => {
assert.equal(normalizeRelayUrl("ws://localhost:3000"), "ws://localhost:3000");
});
test("normalizeRelayUrl_https_converts_to_wss", () => {
assert.equal(
normalizeRelayUrl("https://relay.example.com"),
"wss://relay.example.com",
);
});
test("normalizeRelayUrl_http_converts_to_ws", () => {
assert.equal(
normalizeRelayUrl("http://localhost:3000"),
"ws://localhost:3000",
);
});
test("normalizeRelayUrl_strips_trailing_slashes", () => {
assert.equal(
normalizeRelayUrl("wss://relay.example.com///"),
"wss://relay.example.com",
);
});
test("normalizeRelayUrl_trims_whitespace", () => {
assert.equal(
normalizeRelayUrl(" wss://relay.example.com "),
"wss://relay.example.com",
);
});
test("normalizeRelayUrl_https_with_port_converts_to_wss", () => {
assert.equal(
normalizeRelayUrl("https://relay.example.com:8443"),
"wss://relay.example.com:8443",
);
});
test("normalizeRelayUrl_wss_with_path_preserves_path", () => {
assert.equal(
normalizeRelayUrl("wss://relay.example.com/custom"),
"wss://relay.example.com/custom",
);
});
// ---------------------------------------------------------------------------
// Invalid / rejected inputs
// ---------------------------------------------------------------------------
test("normalizeRelayUrl_empty_returns_null", () => {
assert.equal(normalizeRelayUrl(""), null);
assert.equal(normalizeRelayUrl(" "), null);
});
test("normalizeRelayUrl_bare_hostname_returns_null", () => {
assert.equal(normalizeRelayUrl("relay.example.com"), null);
});
test("normalizeRelayUrl_ftp_scheme_returns_null", () => {
assert.equal(normalizeRelayUrl("ftp://relay.example.com"), null);
});
test("normalizeRelayUrl_garbage_returns_null", () => {
assert.equal(normalizeRelayUrl("not a url at all"), null);
});
@@ -0,0 +1,95 @@
/**
* Normalize a relay URL to ws(s):// form and probe reachability.
*/
/** Normalize a user-entered relay URL to ws(s):// form. Returns null if invalid. */
export function normalizeRelayUrl(input: string): string | null {
const trimmed = input.trim().replace(/\/+$/, "");
if (!trimmed) return null;
// Already ws(s)://
if (trimmed.startsWith("wss://") || trimmed.startsWith("ws://")) {
try {
new URL(trimmed);
return trimmed;
} catch {
return null;
}
}
// Convert https → wss, http → ws
if (trimmed.startsWith("https://")) {
const wsUrl = `wss://${trimmed.slice(8)}`;
try {
new URL(wsUrl);
return wsUrl;
} catch {
return null;
}
}
if (trimmed.startsWith("http://")) {
const wsUrl = `ws://${trimmed.slice(7)}`;
try {
new URL(wsUrl);
return wsUrl;
} catch {
return null;
}
}
return null;
}
/**
* Probe whether a WebSocket relay is reachable. Opens a connection with a
* timeout; resolves `true` if the socket opens, `false` on timeout/error.
* The socket is always closed before returning.
*/
export function probeRelayReachable(
wsUrl: string,
timeoutMs = 4000,
): { promise: Promise<boolean>; cancel: () => void } {
let socket: WebSocket | null = null;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let settled = false;
const promise = new Promise<boolean>((resolve) => {
function settle(result: boolean) {
if (settled) return;
settled = true;
if (timeoutId !== null) clearTimeout(timeoutId);
try {
socket?.close();
} catch {
/* ignore */
}
socket = null;
resolve(result);
}
try {
socket = new WebSocket(wsUrl);
socket.onopen = () => settle(true);
socket.onerror = () => settle(false);
socket.onclose = () => settle(false);
timeoutId = setTimeout(() => settle(false), timeoutMs);
} catch {
settle(false);
}
});
return {
promise,
cancel() {
if (settled) return;
settled = true;
if (timeoutId !== null) clearTimeout(timeoutId);
try {
socket?.close();
} catch {
/* ignore */
}
socket = null;
},
};
}
@@ -0,0 +1,107 @@
/**
* Unit tests for the updateCommunity result matrix (Phase 1).
* Tests the pure decision logic extracted into resolveCommunityUpdateResult.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { resolveCommunityUpdateResult } from "./useCommunities.tsx";
const COMMUNITIES = [
{
id: "ws-1",
name: "Community A",
relayUrl: "wss://relay-a.example.com",
addedAt: "2024-01-01",
},
{
id: "ws-2",
name: "Community B",
relayUrl: "wss://relay-b.example.com",
addedAt: "2024-01-02",
},
];
// ---------------------------------------------------------------------------
// 5-case matrix from the plan
// ---------------------------------------------------------------------------
test("resolveCommunityUpdateResult_untouched_submit_returns_unchanged", () => {
// Prefilled overlay submitted with identical values — no persist, no bump.
const result = resolveCommunityUpdateResult(COMMUNITIES, "ws-1", "ws-1", {
name: "Community A",
relayUrl: "wss://relay-a.example.com",
});
assert.deepEqual(result, { kind: "unchanged" });
});
test("resolveCommunityUpdateResult_name_only_edit_returns_updated_without_reinit", () => {
// Name change persists but does NOT trigger a backend reapply.
const result = resolveCommunityUpdateResult(COMMUNITIES, "ws-1", "ws-1", {
name: "New Name",
});
assert.deepEqual(result, { kind: "updated", requiresReinit: false });
});
test("resolveCommunityUpdateResult_relay_edit_returns_updated_with_reinit", () => {
// Relay URL change on the active community triggers backend reapply.
const result = resolveCommunityUpdateResult(COMMUNITIES, "ws-1", "ws-1", {
relayUrl: "wss://relay-c.example.com",
});
assert.deepEqual(result, { kind: "updated", requiresReinit: true });
});
test("resolveCommunityUpdateResult_duplicate_relay_returns_duplicate", () => {
// Trying to set ws-1's relay to ws-2's relay URL is a duplicate.
const result = resolveCommunityUpdateResult(COMMUNITIES, "ws-1", "ws-1", {
relayUrl: "wss://relay-b.example.com",
});
assert.deepEqual(result, { kind: "duplicate-relay" });
});
test("resolveCommunityUpdateResult_not_found_returns_not_found", () => {
const result = resolveCommunityUpdateResult(
COMMUNITIES,
"ws-1",
"ws-nonexistent",
{
name: "Whatever",
},
);
assert.deepEqual(result, { kind: "not-found" });
});
// ---------------------------------------------------------------------------
// Additional edge cases
// ---------------------------------------------------------------------------
test("resolveCommunityUpdateResult_relay_edit_on_inactive_community_no_reinit", () => {
// Relay change on a NON-active community persists but doesn't reinit.
const result = resolveCommunityUpdateResult(COMMUNITIES, "ws-1", "ws-2", {
relayUrl: "wss://relay-c.example.com",
});
assert.deepEqual(result, { kind: "updated", requiresReinit: false });
});
test("resolveCommunityUpdateResult_token_change_on_active_requires_reinit", () => {
const result = resolveCommunityUpdateResult(COMMUNITIES, "ws-1", "ws-1", {
token: "new-token",
});
assert.deepEqual(result, { kind: "updated", requiresReinit: true });
});
test("resolveCommunityUpdateResult_pubkey_change_does_not_require_reinit", () => {
// pubkey is display-only — not a backend-relevant field.
const result = resolveCommunityUpdateResult(COMMUNITIES, "ws-1", "ws-1", {
pubkey: "newpubkey123",
});
assert.deepEqual(result, { kind: "updated", requiresReinit: false });
});
test("resolveCommunityUpdateResult_same_relay_url_is_not_duplicate_of_self", () => {
// Setting the same relay URL that ws-1 already has is unchanged, not duplicate.
const result = resolveCommunityUpdateResult(COMMUNITIES, "ws-1", "ws-1", {
relayUrl: "wss://relay-a.example.com",
});
assert.deepEqual(result, { kind: "unchanged" });
});
@@ -0,0 +1,51 @@
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
type CommunityApplyErrorScreenProps = {
error: string;
onChangeCommunity: () => void;
onRetry: () => void;
};
export function CommunityApplyErrorScreen({
error,
onChangeCommunity,
onRetry,
}: CommunityApplyErrorScreenProps) {
const systemColorScheme = useSystemColorScheme();
return (
<div
className="buzz-onboarding-neutral-theme buzz-startup-shell flex items-center justify-center bg-background px-4 py-8 text-foreground"
data-system-color-scheme={systemColorScheme}
data-testid="community-apply-error"
>
<StartupWindowDragRegion />
<div className="relative flex w-full max-w-[500px] flex-col items-center text-center">
<h1 className="text-3xl font-semibold tracking-tight">
Community connection failed
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">{error}</p>
<div className="mt-8 flex w-full max-w-[300px] flex-col gap-3">
<Button
className="h-10 w-full"
data-testid="community-apply-error-retry"
onClick={onRetry}
type="button"
>
Retry
</Button>
<Button
className="h-10 w-full"
onClick={onChangeCommunity}
type="button"
variant="secondary"
>
Change community
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,96 @@
import * as React from "react";
import { useCommunities } from "../useCommunities";
import { CommunityEditForm } from "./CommunityEditForm";
type CommunityChangeOverlayProps = {
onClose: () => void;
};
export function CommunityChangeOverlay({
onClose,
}: CommunityChangeOverlayProps) {
const { activeCommunity, updateCommunity } = useCommunities();
const [error, setError] = React.useState<string | null>(null);
const overlayRef = React.useRef<HTMLDivElement>(null);
// Focus trap: focus the overlay on mount
React.useEffect(() => {
overlayRef.current?.focus();
}, []);
// Escape key closes the overlay
React.useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const handleSubmit = React.useCallback(
(name: string, relayUrl: string) => {
if (!activeCommunity) return;
setError(null);
const result = updateCommunity(activeCommunity.id, { name, relayUrl });
switch (result.kind) {
case "unchanged":
onClose();
break;
case "updated":
// If reinit is needed, the communityKey change will trigger a remount.
// If not (name-only), just close.
if (!result.requiresReinit) {
onClose();
}
// If requiresReinit, the tree remounts — overlay unmounts naturally.
break;
case "duplicate-relay":
setError("Another community already uses this relay URL.");
break;
case "not-found":
setError("Community not found.");
break;
}
},
[activeCommunity, onClose, updateCommunity],
);
if (!activeCommunity) return null;
return (
<div
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
data-testid="community-change-overlay"
ref={overlayRef}
role="dialog"
tabIndex={-1}
>
{/* Background click closes */}
<div aria-hidden="true" className="absolute inset-0" onClick={onClose} />
<div className="relative z-10 w-full max-w-md rounded-2xl border border-border bg-background p-8 shadow-2xl">
<h2 className="text-xl font-semibold tracking-tight">
Change community
</h2>
<p className="mt-2 text-sm text-muted-foreground">
Update your community name or relay URL.
</p>
<div className="mt-6">
<CommunityEditForm
initialName={activeCommunity.name}
initialRelayUrl={activeCommunity.relayUrl}
onCancel={onClose}
onSubmit={handleSubmit}
submitLabel="Save changes"
/>
</div>
{error ? (
<p className="mt-4 text-center text-sm text-destructive">{error}</p>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,197 @@
import * as React from "react";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Spinner } from "@/shared/ui/spinner";
import { normalizeRelayUrl, probeRelayReachable } from "../relayProbe";
export type CommunityEditFormProps = {
cancelLabel?: string;
initialName: string;
initialRelayUrl: string;
isSubmitting?: boolean;
onCancel: () => void;
onSubmit: (name: string, relayUrl: string) => void;
submitLabel: string;
};
export function CommunityEditForm({
cancelLabel = "Cancel",
initialName,
initialRelayUrl,
isSubmitting = false,
onCancel,
onSubmit,
submitLabel,
}: CommunityEditFormProps) {
const [name, setName] = React.useState(initialName);
const [relayUrl, setRelayUrl] = React.useState(initialRelayUrl);
const [error, setError] = React.useState<string | null>(null);
const [probeWarning, setProbeWarning] = React.useState<string | null>(null);
const [isProbing, setIsProbing] = React.useState(false);
const [useAnywayOverride, setUseAnywayOverride] = React.useState(false);
const cancelRef = React.useRef<(() => void) | null>(null);
React.useEffect(() => {
return () => {
cancelRef.current?.();
};
}, []);
const handleSubmit = React.useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmedName = name.trim();
if (!trimmedName) {
setError("Please enter a community name.");
return;
}
const normalizedUrl = normalizeRelayUrl(relayUrl);
if (!normalizedUrl) {
setError("Enter a valid ws:// or wss:// relay URL.");
return;
}
if (useAnywayOverride) {
onSubmit(trimmedName, normalizedUrl);
return;
}
// Cancel any in-flight probe before starting a new one.
cancelRef.current?.();
setIsProbing(true);
setProbeWarning(null);
const probe = probeRelayReachable(normalizedUrl);
cancelRef.current = probe.cancel;
let reachable: boolean;
try {
reachable = await probe.promise;
} finally {
setIsProbing(false);
}
if (!reachable) {
setProbeWarning("Can't reach this relay — check the URL");
return;
}
onSubmit(trimmedName, normalizedUrl);
},
[name, onSubmit, relayUrl, useAnywayOverride],
);
const handleUseAnyway = React.useCallback(() => {
setUseAnywayOverride(true);
setProbeWarning(null);
const trimmedName = name.trim();
const normalizedUrl = normalizeRelayUrl(relayUrl);
if (trimmedName && normalizedUrl) {
onSubmit(trimmedName, normalizedUrl);
}
}, [name, onSubmit, relayUrl]);
const isBusy = isSubmitting || isProbing;
return (
<form
className="flex w-full flex-col gap-4"
onSubmit={(e) => {
void handleSubmit(e);
}}
>
<div className="space-y-1.5 text-left">
<label
className="text-sm font-medium text-foreground"
htmlFor="community-edit-name"
>
Community name
</label>
<Input
autoFocus
className="h-10 bg-background"
disabled={isProbing}
id="community-edit-name"
onChange={(event) => {
setName(event.target.value);
setError(null);
}}
placeholder="Design team"
type="text"
value={name}
/>
</div>
<div className="space-y-1.5 text-left">
<label
className="text-sm font-medium text-foreground"
htmlFor="community-edit-url"
>
Community URL
</label>
<Input
className="h-10 bg-background"
disabled={isProbing}
id="community-edit-url"
onChange={(event) => {
setRelayUrl(event.target.value);
setError(null);
setProbeWarning(null);
setUseAnywayOverride(false);
}}
placeholder="wss://relay.example.com"
type="text"
value={relayUrl}
/>
</div>
<div className="flex w-full flex-col gap-3 pt-1">
<Button
className="h-10 w-full"
disabled={isBusy || !name.trim() || !relayUrl.trim()}
type="submit"
>
{isProbing ? (
<Spinner aria-label="Checking relay" className="h-4 w-4 border-2" />
) : isSubmitting ? (
<Spinner aria-label="Saving" className="h-4 w-4 border-2" />
) : (
submitLabel
)}
</Button>
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground"
disabled={isBusy}
onClick={onCancel}
type="button"
variant="ghost"
>
{cancelLabel}
</Button>
{error ? (
<p className="text-center text-sm text-destructive">{error}</p>
) : null}
{probeWarning ? (
<div className="flex flex-col items-center gap-2">
<p className="text-center text-sm text-destructive">
{probeWarning}
</p>
<Button
className="h-8 text-xs"
onClick={handleUseAnyway}
size="sm"
type="button"
variant="outline"
>
Use anyway
</Button>
</div>
) : null}
</div>
</form>
);
}
@@ -5,22 +5,24 @@ import {
getIdentity,
importIdentity as tauriImportIdentity,
} from "@/shared/api/tauriIdentity";
import { claimInvite } from "@/shared/api/invites";
import { inviteErrorMessage } from "@/shared/api/inviteHelpers";
import { InviteRedeemForm } from "@/features/onboarding/ui/InviteRedeemForm";
import { NostrKeyImportForm } from "@/features/onboarding/ui/NostrKeyImportForm";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
} from "@/features/onboarding/ui/OnboardingSlideTransition";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Spinner } from "@/shared/ui/spinner";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { StepProgress } from "@/shared/ui/step-progress";
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
import type { Community } from "../types";
import { initFirstCommunity } from "../communityStorage";
import { CommunityEditForm } from "./CommunityEditForm";
type WelcomeSetupPage = "welcome" | "create-community" | "nostr-key";
type WelcomeSetupPage = "welcome" | "create-community" | "invite" | "nostr-key";
type WelcomeTransitionMode = "initial" | OnboardingTransitionDirection;
type WelcomeSetupProps = {
@@ -91,10 +93,10 @@ export function WelcomeSetup({
const [page, setPage] = React.useState<WelcomeSetupPage>("welcome");
const [transitionMode, setTransitionMode] =
React.useState<WelcomeTransitionMode>(initialTransitionMode);
const [customCommunityName, setCustomCommunityName] = React.useState("");
const [customRelayUrl, setCustomRelayUrl] = React.useState("");
const [isConnecting, setIsConnecting] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [isRedeeming, setIsRedeeming] = React.useState(false);
const [inviteError, setInviteError] = React.useState<string | null>(null);
const systemColorScheme = useSystemColorScheme();
const handleConnect = React.useCallback(
@@ -156,22 +158,20 @@ export function WelcomeSetup({
[defaultRelayUrl, handleConnect],
);
const handleCustomCommunitySubmit = React.useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmedName = customCommunityName.trim();
const trimmedUrl = customRelayUrl.trim();
if (!trimmedName) {
setError("Please enter a community name.");
return;
const handleWelcomeInviteRedeem = React.useCallback(
async (relayWsUrl: string, code: string) => {
setIsRedeeming(true);
setInviteError(null);
try {
await claimInvite(relayWsUrl, code);
await handleConnect(relayWsUrl);
} catch (err) {
setInviteError(inviteErrorMessage(err));
} finally {
setIsRedeeming(false);
}
if (!trimmedUrl) {
setError("Please enter a community URL.");
return;
}
void handleConnect(trimmedUrl, trimmedName);
},
[customRelayUrl, customCommunityName, handleConnect],
[handleConnect],
);
const showCreateCommunityPage = React.useCallback(() => {
@@ -180,6 +180,12 @@ export function WelcomeSetup({
setPage("create-community");
}, []);
const showInvitePage = React.useCallback(() => {
setInviteError(null);
setTransitionMode("forward");
setPage("invite");
}, []);
const showNostrKeyPage = React.useCallback(() => {
setError(null);
setTransitionMode("forward");
@@ -188,12 +194,19 @@ export function WelcomeSetup({
const showWelcomePage = React.useCallback(() => {
setError(null);
setInviteError(null);
setTransitionMode("backward");
setPage("welcome");
}, []);
const currentStep =
page === "welcome" ? (isConnecting ? 2 : 1) : page === "nostr-key" ? 1 : 2;
page === "welcome"
? isConnecting
? 2
: 1
: page === "nostr-key" || page === "invite"
? 1
: 2;
const transitionDirection =
transitionMode === "backward" ? "backward" : "forward";
const welcomeEffect =
@@ -268,6 +281,21 @@ export function WelcomeSetup({
Join a community
</Button>
<Button
className="h-10 w-full"
aria-disabled={isConnecting}
onClick={() => {
if (isConnecting) {
return;
}
showInvitePage();
}}
type="button"
variant="ghost"
>
Have an invite?
</Button>
<Button
className="h-10 w-full"
aria-disabled={isConnecting}
@@ -307,88 +335,56 @@ export function WelcomeSetup({
</p>
</div>
<form
className="mt-8 flex w-full flex-col gap-4"
onSubmit={handleCustomCommunitySubmit}
>
<div className="space-y-1.5 text-left">
<label
className="text-sm font-medium text-foreground"
htmlFor="community-name"
>
Community name
</label>
<Input
autoFocus
className="h-10 bg-background"
id="community-name"
onChange={(event) => {
setCustomCommunityName(event.target.value);
setError(null);
}}
placeholder="Design team"
type="text"
value={customCommunityName}
/>
</div>
<div className="mt-8 w-full">
<CommunityEditForm
cancelLabel="Back"
initialName=""
initialRelayUrl=""
isSubmitting={isConnecting}
onCancel={showWelcomePage}
onSubmit={(name, url) => {
void handleConnect(url, name);
}}
submitLabel="Join a community"
/>
{error ? (
<p className="mt-2 text-center text-sm text-destructive">
{error}
</p>
) : null}
</div>
</OnboardingSlideTransition>
) : page === "invite" ? (
<OnboardingSlideTransition
className="flex w-full flex-col items-center text-center"
direction={transitionDirection}
transitionKey={`invite-${transitionDirection}`}
>
<div className="w-full max-w-[440px]">
<h1 className="text-3xl font-semibold tracking-tight">
Redeem an invite
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Paste an invite link or code from a relay admin to join their
community.
</p>
</div>
<div className="space-y-1.5 text-left">
<label
className="text-sm font-medium text-foreground"
htmlFor="community-url"
>
Community URL
</label>
<Input
className="h-10 bg-background"
id="community-url"
onChange={(event) => {
setCustomRelayUrl(event.target.value);
setError(null);
}}
placeholder="wss://relay.example.com"
type="text"
value={customRelayUrl}
/>
</div>
<div className="flex w-full flex-col gap-3 pt-1">
<Button
className="h-10 w-full"
disabled={
isConnecting ||
!customCommunityName.trim() ||
!customRelayUrl.trim()
}
type="submit"
>
{isConnecting ? (
<Spinner
aria-label="Joining community"
className="h-4 w-4 border-2"
/>
) : (
"Join a community"
)}
</Button>
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground"
disabled={isConnecting}
onClick={showWelcomePage}
type="button"
variant="ghost"
>
Back
</Button>
{error ? (
<p className="text-center text-sm text-destructive">
{error}
</p>
) : null}
</div>
</form>
<div className="mt-8 w-full">
<InviteRedeemForm
defaultRelayUrl={
isLocalDevRelayUrl(defaultRelayUrl)
? undefined
: defaultRelayUrl
}
error={inviteError}
isRedeeming={isRedeeming}
onCancel={showWelcomePage}
onRedeem={(relayWsUrl, code) => {
void handleWelcomeInviteRedeem(relayWsUrl, code);
}}
/>
</div>
</OnboardingSlideTransition>
) : (
<NostrKeyImportPage
@@ -21,6 +21,57 @@ import { removeChannelSnapshotForRelay } from "@/features/channels/channelSnapsh
import { removeMessageSnapshotsForRelay } from "@/features/messages/lib/messageSnapshot";
import { clearSavedCommunitySnapshot } from "@/features/agents/activeAgentTurnsStore";
export type UpdateCommunityResult =
| { kind: "updated"; requiresReinit: boolean }
| { kind: "unchanged" }
| { kind: "duplicate-relay" }
| { kind: "not-found" };
/**
* Pure decision logic for updateCommunity — determines the outcome from a
* synchronous snapshot of communities without side effects. Extracted so the
* 5-case result matrix is unit-testable outside React.
*/
export function resolveCommunityUpdateResult(
communities: Community[],
activeId: string | null,
id: string,
updates: Partial<
Pick<Community, "name" | "relayUrl" | "token" | "pubkey" | "reposDir">
>,
): UpdateCommunityResult {
const current = communities.find((w) => w.id === id);
if (!current) return { kind: "not-found" };
if (
updates.relayUrl !== undefined &&
updates.relayUrl !== current.relayUrl &&
communities.some((w) => w.id !== id && w.relayUrl === updates.relayUrl)
) {
return { kind: "duplicate-relay" };
}
const hasChange =
(updates.name !== undefined && updates.name !== current.name) ||
(updates.relayUrl !== undefined && updates.relayUrl !== current.relayUrl) ||
(updates.token !== undefined && updates.token !== current.token) ||
(updates.pubkey !== undefined && updates.pubkey !== current.pubkey) ||
(updates.reposDir !== undefined && updates.reposDir !== current.reposDir);
if (!hasChange) return { kind: "unchanged" };
const isActive = id === activeId;
const backendFieldsChanged =
isActive &&
((updates.relayUrl !== undefined &&
updates.relayUrl !== current.relayUrl) ||
(updates.token !== undefined && updates.token !== current.token) ||
(updates.reposDir !== undefined &&
updates.reposDir !== current.reposDir));
return { kind: "updated", requiresReinit: backendFieldsChanged };
}
export type UseCommunitiesReturn = {
communities: Community[];
activeCommunity: Community | null;
@@ -38,7 +89,7 @@ export type UseCommunitiesReturn = {
updates: Partial<
Pick<Community, "name" | "relayUrl" | "token" | "pubkey" | "reposDir">
>,
) => void;
) => UpdateCommunityResult;
};
const CommunitiesContext = createContext<UseCommunitiesReturn | null>(null);
@@ -164,27 +215,29 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
updates: Partial<
Pick<Community, "name" | "relayUrl" | "token" | "pubkey" | "reposDir">
>,
) => {
setCommunitiesState((prev) => {
// Prevent duplicate relay URLs across communities
if (
updates.relayUrl &&
prev.some((w) => w.id !== id && w.relayUrl === updates.relayUrl)
) {
return prev;
): UpdateCommunityResult => {
const result = resolveCommunityUpdateResult(
communitiesRef.current,
activeId,
id,
updates,
);
if (result.kind === "updated") {
setCommunitiesState((prev) => {
const next = prev.map((w) =>
w.id === id ? { ...w, ...updates } : w,
);
saveCommunities(next);
return next;
});
if (result.requiresReinit) {
setReinitKey((k) => k + 1);
}
const next = prev.map((w) => (w.id === id ? { ...w, ...updates } : w));
saveCommunities(next);
return next;
});
// If the active community's relay URL or token changed, bump reinitKey
// so the React tree remounts with the new config.
if (
id === activeId &&
(updates.relayUrl || updates.token !== undefined)
) {
setReinitKey((k) => k + 1);
}
return result;
},
[activeId],
);
@@ -48,7 +48,8 @@ type CommunityInitResult =
needsSetup: true;
defaultRelayUrl: string;
}
| { isReady: false; needsSetup: false; appliedKey: string | null };
| { isReady: false; needsSetup: false; appliedKey: string | null }
| { isReady: false; needsSetup: false; appliedKey: null; error: string };
/**
* Applies the active community config to the Tauri backend and resets
@@ -169,7 +170,15 @@ export function useCommunityInit(
// the loading gate (isReady:false, no appliedKey) instead.
console.error("Failed to apply community to backend:", error);
if (!cancelled) {
setResult({ isReady: false, needsSetup: false, appliedKey: null });
setResult({
isReady: false,
needsSetup: false,
appliedKey: null,
error:
error instanceof Error
? error.message
: "Failed to apply community configuration",
});
}
return;
}
+112 -12
View File
@@ -1,5 +1,6 @@
import * as React from "react";
import { useQueryClient, type QueryStatus } from "@tanstack/react-query";
import { toast } from "sonner";
import {
managedAgentsQueryKey,
@@ -30,9 +31,11 @@ import {
const DEFAULT_AUTO_JOIN_CHANNEL_NAME = "general";
export type ChannelInitResult = { ok: true } | { ok: false; reason: string };
async function autoJoinDefaultChannel(
queryClient: ReturnType<typeof useQueryClient>,
) {
): Promise<ChannelInitResult> {
try {
const channels = await getChannels();
const target = channels.find(
@@ -40,13 +43,19 @@ async function autoJoinDefaultChannel(
channel.name === DEFAULT_AUTO_JOIN_CHANNEL_NAME && !channel.isMember,
);
if (!target) {
return;
return { ok: true };
}
await joinChannel(target.id);
await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
} catch {
// Silent: auto-join is best-effort. The Welcome channel is created
// separately, and users can still join channels manually from the browser.
return { ok: true };
} catch (error) {
return {
ok: false,
reason:
error instanceof Error
? error.message
: "Failed to join the general channel",
};
}
}
@@ -61,7 +70,7 @@ async function initializeWelcomeChannel(
pubkey: string | null;
communityScope: string | null;
},
) {
): Promise<ChannelInitResult> {
try {
const allowedMemberPubkeys = await getWelcomeGuideAgentPubkeys(
communityScope,
@@ -98,8 +107,16 @@ async function initializeWelcomeChannel(
if (focus) {
notifyWelcomeChannelReady(welcomeChannel.id);
}
return { ok: true };
} catch (error) {
console.warn("Failed to initialize Welcome channel.", error);
return {
ok: false,
reason:
error instanceof Error
? error.message
: "Failed to create the Welcome channel",
};
}
}
@@ -404,7 +421,7 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
const currentPubkey = identity?.pubkey ?? null;
const welcomeChannelCommunityScope = activeCommunity?.relayUrl ?? null;
const welcomeChannelInitPromisesRef = React.useRef(
new Map<string, Promise<void>>(),
new Map<string, Promise<ChannelInitResult>>(),
);
const [isCompletingWelcomeSetup, setIsCompletingWelcomeSetup] =
React.useState(false);
@@ -425,6 +442,14 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
if (identityLost) setBootedLost(true);
}, [identityLost]);
// Sticky boot fact: once identity was locked at boot, this remains true for
// the entire session. After import_identity clears the locked flag, the
// relaunchRequired derivation uses this to force the relaunch screen.
const [bootedLocked, setBootedLocked] = React.useState(false);
React.useEffect(() => {
if (identityLocked) setBootedLocked(true);
}, [identityLocked]);
const profileQuery = useProfileQuery(
!identityLost && !identityLocked && identityQuery.status === "success",
);
@@ -439,10 +464,11 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
profileStatus: profileQuery.status,
});
const gateComplete = onboardingGate.complete;
const welcomeChannelFocusIntentRef = React.useRef(new Map<string, boolean>());
const requestWelcomeChannel = React.useCallback(
(focus: boolean) => {
(focus: boolean): Promise<ChannelInitResult> => {
if (!currentPubkey || !welcomeChannelCommunityScope) {
return Promise.resolve();
return Promise.resolve({ ok: true });
}
const welcomeChannelInitKey = `${welcomeChannelCommunityScope}:${currentPubkey}`;
@@ -450,9 +476,29 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
welcomeChannelInitKey,
);
if (currentPromise) {
// A focus=true request must not be swallowed behind an in-flight
// focus=false promise. Upgrade the intent: when the background
// promise resolves, chain a focus-only follow-up.
if (
focus &&
!welcomeChannelFocusIntentRef.current.get(welcomeChannelInitKey)
) {
welcomeChannelFocusIntentRef.current.set(welcomeChannelInitKey, true);
return currentPromise.then((result) => {
if (!result.ok) return result;
return initializeWelcomeChannel(queryClient, {
focus: true,
pubkey: currentPubkey,
communityScope: welcomeChannelCommunityScope,
});
});
}
return currentPromise;
}
if (focus) {
welcomeChannelFocusIntentRef.current.set(welcomeChannelInitKey, true);
}
const promise = initializeWelcomeChannel(queryClient, {
focus,
pubkey: currentPubkey,
@@ -461,6 +507,7 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
welcomeChannelInitPromisesRef.current.set(welcomeChannelInitKey, promise);
void promise.finally(() => {
welcomeChannelInitPromisesRef.current.delete(welcomeChannelInitKey);
welcomeChannelFocusIntentRef.current.delete(welcomeChannelInitKey);
});
return promise;
},
@@ -486,6 +533,44 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
welcomeChannelCommunityScope,
]);
const showWelcomeRetryToast = React.useCallback(
(reason: string) => {
toast.error("Couldn't set up the Welcome channel", {
action: {
label: "Retry",
onClick: () => {
void requestWelcomeChannel(true).then((result) => {
if (!result.ok) {
showWelcomeRetryToast(result.reason);
}
});
},
},
description: reason,
});
},
[requestWelcomeChannel],
);
const showGeneralRetryToast = React.useCallback(
(reason: string) => {
toast.error("Couldn't join #general", {
action: {
label: "Retry",
onClick: () => {
void autoJoinDefaultChannel(queryClient).then((result) => {
if (!result.ok) {
showGeneralRetryToast(result.reason);
}
});
},
},
description: reason,
});
},
[queryClient],
);
const completeAndShowWelcome = React.useCallback(() => {
setIsCompletingWelcomeSetup(true);
gateComplete();
@@ -493,11 +578,25 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
requestWelcomeChannel(true),
autoJoinDefaultChannel(queryClient),
])
.then(() => refreshChannelsCache(queryClient))
.then(([welcomeResult, autoJoinResult]) => {
if (!welcomeResult.ok) {
showWelcomeRetryToast(welcomeResult.reason);
}
if (!autoJoinResult.ok) {
showGeneralRetryToast(autoJoinResult.reason);
}
return refreshChannelsCache(queryClient);
})
.finally(() => {
setIsCompletingWelcomeSetup(false);
});
}, [gateComplete, queryClient, requestWelcomeChannel]);
}, [
gateComplete,
queryClient,
requestWelcomeChannel,
showGeneralRetryToast,
showWelcomeRetryToast,
]);
const flow = {
actions: {
complete: completeAndShowWelcome,
@@ -513,7 +612,8 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
// pending-event flush) were skipped for the ephemeral key and cannot restart
// in-process, so nothing else can proceed until the app restarts.
const relaunchRequired =
bootedLost && !identityLost && identityQuery.status === "success";
((bootedLost && !identityLost) || (bootedLocked && !identityLocked)) &&
identityQuery.status === "success";
return {
currentPubkey,
@@ -0,0 +1,133 @@
import * as React from "react";
import { parseInviteInput } from "@/shared/api/inviteHelpers";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Spinner } from "@/shared/ui/spinner";
type InviteRedeemFormProps = {
/**
* Pre-fill and expose a relay URL field for bare-code inputs.
* On MembershipDenied this is the active relay; on WelcomeSetup it is the
* configured default relay. Omit to silently reject bare-code inputs
* (the form stays invalid until a full invite URL is entered).
*/
defaultRelayUrl?: string;
error: string | null;
isRedeeming: boolean;
onCancel: () => void;
onRedeem: (relayWsUrl: string, code: string) => void;
};
export function InviteRedeemForm({
defaultRelayUrl,
error,
isRedeeming,
onCancel,
onRedeem,
}: InviteRedeemFormProps) {
const [inviteInput, setInviteInput] = React.useState("");
const [bareCodeRelayUrl, setBareCodeRelayUrl] = React.useState(
defaultRelayUrl ?? "",
);
const parsed = React.useMemo(
() => parseInviteInput(inviteInput),
[inviteInput],
);
const isBareCode = parsed !== null && !("relayWsUrl" in parsed);
const needsRelayField = isBareCode && defaultRelayUrl !== undefined;
const canSubmit =
parsed !== null &&
("relayWsUrl" in parsed ||
(isBareCode && bareCodeRelayUrl.trim().length > 0));
const handleSubmit = React.useCallback(
(event: React.FormEvent) => {
event.preventDefault();
if (!parsed) return;
if ("relayWsUrl" in parsed) {
onRedeem(parsed.relayWsUrl, parsed.code);
} else if (bareCodeRelayUrl.trim()) {
onRedeem(bareCodeRelayUrl.trim(), parsed.code);
}
},
[bareCodeRelayUrl, onRedeem, parsed],
);
return (
<form className="flex w-full flex-col gap-3" onSubmit={handleSubmit}>
<div className="space-y-1.5 text-left">
<label
className="text-sm font-medium text-foreground"
htmlFor="invite-input"
>
Invite link or code
</label>
<Input
autoComplete="off"
autoCorrect="off"
autoFocus
className="h-10 bg-background"
data-testid="invite-redeem-input"
disabled={isRedeeming}
id="invite-input"
onChange={(event) => setInviteInput(event.target.value)}
placeholder="https://relay.example.com/invite/abc123 or paste a code"
spellCheck={false}
type="text"
value={inviteInput}
/>
</div>
{needsRelayField ? (
<div className="space-y-1.5 text-left">
<label
className="text-sm font-medium text-foreground"
htmlFor="invite-relay-url"
>
Relay URL
</label>
<Input
className="h-10 bg-background"
disabled={isRedeeming}
id="invite-relay-url"
onChange={(event) => setBareCodeRelayUrl(event.target.value)}
placeholder="wss://relay.example.com"
type="text"
value={bareCodeRelayUrl}
/>
</div>
) : null}
{error ? (
<p className="text-center text-sm text-destructive">{error}</p>
) : null}
<Button
className="h-10 w-full"
data-testid="invite-redeem-submit"
disabled={!canSubmit || isRedeeming}
type="submit"
>
{isRedeeming ? (
<Spinner aria-label="Redeeming invite" className="h-4 w-4 border-2" />
) : (
"Redeem invite"
)}
</Button>
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground"
disabled={isRedeeming}
onClick={onCancel}
type="button"
variant="ghost"
>
Cancel
</Button>
</form>
);
}
@@ -1,11 +1,83 @@
import { RecoveryScreen } from "./RecoveryScreen";
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { relaunch } from "@tauri-apps/plugin-process";
import { importIdentity } from "@/shared/api/tauriIdentity";
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { NostrKeyImportForm } from "./NostrKeyImportForm";
export function KeyringLockedScreen() {
const queryClient = useQueryClient();
const systemColorScheme = useSystemColorScheme();
const [showImport, setShowImport] = React.useState(false);
const handleReimportClick = React.useCallback(() => {
const confirmed = window.confirm(
"Importing a different nsec replaces the identity currently locked in the keyring for this install. The previous identity will no longer be accessible. Continue?",
);
if (confirmed) {
setShowImport(true);
}
}, []);
const handleImport = React.useCallback(
async (nsec: string) => {
const identity = await importIdentity(nsec);
// Update the identity query cache so useIdentityQuery observers see
// locked: false. The bootedLocked latch in hooks.ts will then route
// to RelaunchRequiredScreen via bootedLocked && !identityLocked.
queryClient.setQueryData(["identity"], identity);
},
[queryClient],
);
return (
<RecoveryScreen
testId="keyring-locked"
title="Unlock your system keyring"
body="Your identity is safe in the OS keyring, but it's unreachable this session. Unlock your keyring or sign into your desktop session, then relaunch Buzz."
/>
<div
className="buzz-onboarding-neutral-theme buzz-startup-shell flex items-center justify-center bg-background px-4 py-8 text-foreground"
data-system-color-scheme={systemColorScheme}
data-testid="keyring-locked"
>
<StartupWindowDragRegion />
<div className="relative flex w-full max-w-[500px] flex-col items-center text-center">
<h1 className="text-3xl font-semibold tracking-tight">
Unlock your system keyring
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Your identity is safe in the OS keyring, but it's unreachable this
session. Unlock your keyring or sign into your desktop session, then
relaunch Buzz.
</p>
{showImport ? (
<NostrKeyImportForm
backLabel="Cancel"
onBack={() => setShowImport(false)}
onImport={handleImport}
/>
) : (
<div className="mt-8 flex w-full max-w-[300px] flex-col gap-3">
<Button
className="h-10 w-full"
data-testid="relaunch-app"
onClick={() => {
void relaunch();
}}
type="button"
>
Relaunch Buzz
</Button>
<Button
className="h-10 w-full"
onClick={handleReimportClick}
type="button"
variant="secondary"
>
Re-import your key instead
</Button>
</div>
)}
</div>
</div>
);
}
@@ -1,23 +1,34 @@
import * as React from "react";
import { Check, Copy, KeyRound, ShieldX } from "lucide-react";
import { Check, Copy, KeyRound, ShieldX, Ticket } from "lucide-react";
import { claimInvite } from "@/shared/api/invites";
import { inviteErrorMessage } from "@/shared/api/inviteHelpers";
import { nsecToNpub, pubkeyToNpub } from "@/shared/lib/nostrUtils";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Spinner } from "@/shared/ui/spinner";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { InviteRedeemForm } from "./InviteRedeemForm";
type MembershipDeniedProps = {
onChangeKey?: () => void;
onImportKey?: (nsec: string) => Promise<void>;
/** The relay that denied membership — used as the target for bare-code invites. */
activeRelayUrl: string;
onBack: () => void;
onChangeCommunity: () => void;
onImportKey: (nsec: string) => Promise<void>;
/** Called with the relay URL where membership was successfully claimed. */
onInviteRedeemed: (relayWsUrl: string) => void;
onRetry: () => void;
pubkey: string;
};
export function MembershipDenied({
onChangeKey,
activeRelayUrl,
onBack,
onChangeCommunity,
onImportKey,
onInviteRedeemed,
onRetry,
pubkey,
}: MembershipDeniedProps) {
@@ -39,9 +50,12 @@ export function MembershipDenied({
const [nsecInput, setNsecInput] = React.useState("");
const previewNpub = React.useMemo(() => nsecToNpub(nsecInput), [nsecInput]);
const trimmedNsec = nsecInput.trim();
const canImportKey = typeof onImportKey === "function";
const isValidNsec = previewNpub !== null;
const [isInviteFormOpen, setIsInviteFormOpen] = React.useState(false);
const [isRedeeming, setIsRedeeming] = React.useState(false);
const [inviteError, setInviteError] = React.useState<string | null>(null);
const handleCopy = React.useCallback(async () => {
try {
await navigator.clipboard.writeText(npub);
@@ -53,10 +67,6 @@ export function MembershipDenied({
}, [npub]);
const handleImportKey = React.useCallback(async () => {
if (!onImportKey) {
return;
}
if (!previewNpub) {
setImportError(
"That doesn't look like a valid nsec. Paste an nsec1 key.",
@@ -78,6 +88,22 @@ export function MembershipDenied({
}
}, [onImportKey, previewNpub, trimmedNsec]);
const handleInviteRedeem = React.useCallback(
async (relayWsUrl: string, code: string) => {
setIsRedeeming(true);
setInviteError(null);
try {
await claimInvite(relayWsUrl, code);
onInviteRedeemed(relayWsUrl);
} catch (error) {
setInviteError(inviteErrorMessage(error));
} finally {
setIsRedeeming(false);
}
},
[onInviteRedeemed],
);
return (
<div
className="flex min-h-dvh items-center justify-center bg-[radial-gradient(circle_at_top,hsl(var(--primary)/0.14),transparent_48%),linear-gradient(180deg,hsl(var(--background)),hsl(var(--muted)/0.55))] px-4 py-8"
@@ -135,7 +161,20 @@ export function MembershipDenied({
</div>
<div className="mt-6 flex flex-col gap-2">
{isImportFormOpen ? (
{isInviteFormOpen ? (
<InviteRedeemForm
defaultRelayUrl={activeRelayUrl}
error={inviteError}
isRedeeming={isRedeeming}
onCancel={() => {
setInviteError(null);
setIsInviteFormOpen(false);
}}
onRedeem={(relayWsUrl, code) => {
void handleInviteRedeem(relayWsUrl, code);
}}
/>
) : isImportFormOpen ? (
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
@@ -224,25 +263,48 @@ export function MembershipDenied({
<Button className="w-full" onClick={onRetry} type="button">
Try again
</Button>
{onChangeKey || canImportKey ? (
<button
className="flex w-full items-center justify-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
data-testid="membership-denied-change-key"
onClick={() => {
if (onChangeKey) {
onChangeKey();
return;
}
setImportError(null);
setIsImportFormOpen(true);
}}
<div className="flex gap-2">
<Button
className="flex-1 text-muted-foreground hover:text-accent-foreground"
onClick={onBack}
type="button"
variant="ghost"
>
<KeyRound className="h-4 w-4" />
Use a different key
</button>
) : null}
Back
</Button>
<Button
className="flex-1 text-muted-foreground hover:text-accent-foreground"
onClick={onChangeCommunity}
type="button"
variant="ghost"
>
Change community
</Button>
</div>
<button
className="flex w-full items-center justify-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
data-testid="membership-denied-redeem-invite"
onClick={() => {
setInviteError(null);
setIsInviteFormOpen(true);
}}
type="button"
>
<Ticket className="h-4 w-4" />
Have an invite?
</button>
<button
className="flex w-full items-center justify-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
data-testid="membership-denied-change-key"
onClick={() => {
setImportError(null);
setIsImportFormOpen(true);
}}
type="button"
>
<KeyRound className="h-4 w-4" />
Use a different key
</button>
</>
)}
</div>
@@ -20,12 +20,15 @@ import {
} from "@/shared/theme/ThemeProvider";
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
import { ONBOARDING_DEFAULT_THEME_NAME } from "@/shared/theme/theme-loader";
import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { StepProgress } from "@/shared/ui/step-progress";
import { AvatarStep } from "./AvatarStep";
import { BackupStep } from "./BackupStep";
import { MembershipDenied } from "./MembershipDenied";
import { NostrKeyImportForm } from "./NostrKeyImportForm";
import { useCommunities } from "@/features/communities/useCommunities";
import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
@@ -41,14 +44,6 @@ import type {
ProfileStepState,
} from "./types";
/**
* Check whether the relay denies access due to membership gating.
*
* Uses the standard relay message path to read the NIP-43 membership snapshot.
*
* Returns `true` if denied, `false` if the user is a member (or if the
* relay doesn't enforce membership / isn't reachable).
*/
function isRelayMembershipDeniedError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
@@ -62,25 +57,37 @@ function isRelayMembershipDeniedError(error: unknown): boolean {
);
}
async function checkMembershipDenied(): Promise<boolean> {
type MembershipCheckResult = "denied" | "ok" | "unreachable" | "error";
async function checkMembershipStatus(): Promise<MembershipCheckResult> {
try {
const { membership, snapshotFound } = await getMyRelayMembershipLookup();
return snapshotFound && membership === null;
if (snapshotFound && membership === null) return "denied";
return "ok";
} catch (error) {
if (isRelayMembershipDeniedError(error)) {
return true;
if (isRelayMembershipDeniedError(error)) return "denied";
if (error instanceof Error) {
const msg = error.message.toLowerCase();
if (
msg.includes("failed to fetch") ||
msg.includes("networkerror") ||
msg.includes("timeout") ||
msg.includes("econnrefused") ||
msg.includes("enotfound") ||
msg.includes("connection") ||
msg.includes("aborted")
) {
return "unreachable";
}
}
// Network errors, 401s, 500s — not membership denials.
return false;
return "error";
}
}
type OnboardingFlowProps = {
actions: OnboardingActions;
canBackToCommunitySetup: boolean;
identityLost?: boolean;
initialProfile: OnboardingProfileSeed;
onBackToCommunitySetup: () => void;
};
function isFallbackDisplayName(value?: string | null) {
@@ -147,12 +154,12 @@ function resolveProfileSaveRecovery(
export function OnboardingFlow({
actions,
canBackToCommunitySetup,
identityLost = false,
initialProfile,
onBackToCommunitySetup,
}: OnboardingFlowProps) {
const { complete, skipForNow } = actions;
const { activeCommunity, communities, updateCommunity, switchCommunity } =
useCommunities();
const queryClient = useQueryClient();
const savedProfile = resolveSavedProfile(initialProfile);
const profileUpdateMutation = useUpdateProfileMutation();
@@ -175,6 +182,14 @@ export function OnboardingFlow({
const [identityWasImported, setIdentityWasImported] = React.useState(false);
const [membershipRetryPage, setMembershipRetryPage] =
React.useState<OnboardingPage>("avatar");
const [deniedFromPage, setDeniedFromPage] =
React.useState<OnboardingPage>("profile");
const [isCommunityChangeOpen, setIsCommunityChangeOpen] =
React.useState(false);
const [membershipError, setMembershipError] = React.useState<{
kind: "unreachable" | "error";
message?: string;
} | null>(null);
const [transitionDirection, setTransitionDirection] =
React.useState<OnboardingTransitionDirection>("forward");
const systemColorScheme = useSystemColorScheme();
@@ -247,6 +262,7 @@ export function OnboardingFlow({
);
const showProfilePage = React.useCallback(() => {
setMembershipError(null);
setTransitionDirection("backward");
setCurrentPage("profile");
}, []);
@@ -277,19 +293,37 @@ export function OnboardingFlow({
try {
// Check membership before attempting the profile save. On open relays
// this passes instantly. On gated relays it prevents a 403 during save.
const denied = await checkMembershipDenied();
if (denied) {
const membershipStatus = await checkMembershipStatus();
setMembershipError(null);
if (membershipStatus === "denied") {
try {
const identity = await getIdentity();
setDeniedPubkey(identity.pubkey);
} catch {
setDeniedPubkey("");
}
setDeniedFromPage((prev) =>
currentPage === "membership-denied" ? prev : currentPage,
);
setMembershipRetryPage(nextPage);
setCurrentPage("membership-denied");
return;
}
if (membershipStatus === "unreachable") {
setMembershipError({ kind: "unreachable" });
return;
}
if (membershipStatus === "error") {
setMembershipError({
kind: "error",
message: "Server error — try again",
});
return;
}
const updatePayload = createProfileUpdatePayload({
draftProfile: profileDraft,
savedProfile,
@@ -306,6 +340,9 @@ export function OnboardingFlow({
} catch {
setDeniedPubkey("");
}
setDeniedFromPage((prev) =>
currentPage === "membership-denied" ? prev : currentPage,
);
setMembershipRetryPage(nextPage);
setCurrentPage("membership-denied");
return;
@@ -328,6 +365,7 @@ export function OnboardingFlow({
}
},
[
currentPage,
isProfileAdvancePending,
profileDraft,
profileUpdateMutation,
@@ -464,184 +502,260 @@ export function OnboardingFlow({
}
}, [queryClient]);
const handleInviteRedeemed = React.useCallback(
(relayWsUrl: string) => {
if (!activeCommunity) return;
// Same-relay: membership claim updated this relay — just retry.
if (relayWsUrl === activeCommunity.relayUrl) {
void saveProfileAndContinue(membershipRetryPage);
return;
}
// Cross-relay: point the active community at the invite's relay.
// If that relay already exists as another community, switch to it.
const result = updateCommunity(activeCommunity.id, {
relayUrl: relayWsUrl,
});
if (result.kind === "duplicate-relay") {
const existing = communities.find((w) => w.relayUrl === relayWsUrl);
if (existing) {
switchCommunity(existing.id);
}
}
// On "updated", the reinitKey bump triggers a remount that re-runs the
// membership gate automatically.
},
[
activeCommunity,
communities,
membershipRetryPage,
saveProfileAndContinue,
switchCommunity,
updateCommunity,
],
);
if (currentPage === "membership-denied") {
return (
<MembershipDenied
onChangeKey={
canBackToCommunitySetup
? () => {
setTransitionDirection("backward");
onBackToCommunitySetup();
}
: undefined
}
onImportKey={canBackToCommunitySetup ? undefined : importExistingKey}
onRetry={() => {
void saveProfileAndContinue(membershipRetryPage);
}}
pubkey={deniedPubkey}
/>
<>
<MembershipDenied
activeRelayUrl={activeCommunity?.relayUrl ?? ""}
onBack={() => {
setTransitionDirection("backward");
setCurrentPage(deniedFromPage);
}}
onChangeCommunity={() => setIsCommunityChangeOpen(true)}
onImportKey={importExistingKey}
onInviteRedeemed={handleInviteRedeemed}
onRetry={() => {
void saveProfileAndContinue(membershipRetryPage);
}}
pubkey={deniedPubkey}
/>
{isCommunityChangeOpen ? (
<CommunityChangeOverlay
onClose={() => setIsCommunityChangeOpen(false)}
/>
) : null}
</>
);
}
return (
<div
className={`buzz-startup-shell flex items-center justify-center bg-background px-4 py-8 text-foreground ${
currentPage === "profile" ||
currentPage === "backup" ||
currentPage === "avatar" ||
currentPage === "key-import"
? "buzz-onboarding-neutral-theme"
: ""
}`}
data-testid="onboarding-gate"
data-system-color-scheme={systemColorScheme}
>
<StartupWindowDragRegion />
<>
<div
className={`relative flex w-full flex-col items-center text-center ${
currentPage === "theme"
? "max-w-[1180px]"
: currentPage === "avatar"
? "max-w-[1080px]"
: currentPage === "setup"
? "max-w-[920px]"
: "max-w-[500px]"
className={`buzz-startup-shell flex items-start justify-center overflow-y-auto bg-background px-4 py-8 text-foreground ${
currentPage === "profile" ||
currentPage === "backup" ||
currentPage === "avatar" ||
currentPage === "key-import"
? "buzz-onboarding-neutral-theme"
: ""
}`}
data-testid="onboarding-gate"
data-system-color-scheme={systemColorScheme}
>
<OnboardingSlideTransition
className="w-auto"
containerClassName={`fixed bottom-12 left-1/2 z-40 w-auto -translate-x-1/2 ${
hideFixedProgressOnCompact ? "max-lg:hidden" : ""
}`}
direction={transitionDirection}
transitionKey={`progress-${currentStep}-${transitionDirection}-${
hideFixedProgressOnCompact ? "compact-hidden" : "visible"
<StartupWindowDragRegion />
<div
className={`relative my-auto flex w-full flex-col items-center text-center ${
currentPage === "theme"
? "max-w-[1180px]"
: currentPage === "avatar"
? "max-w-[1080px]"
: currentPage === "setup"
? "max-w-[920px]"
: "max-w-[500px]"
}`}
>
<StepProgress
activeSegmentClassName="bg-primary"
completeSegmentClassName="bg-primary/35"
currentStep={currentStep}
inactiveSegmentClassName="bg-muted-foreground/25"
totalSteps={totalOnboardingSteps}
/>
</OnboardingSlideTransition>
{currentPage === "profile" ? (
<ProfileStep
actions={{
advanceWithoutSaving: advanceFromProfileWithoutSaving,
back: canBackToCommunitySetup
? () => {
setTransitionDirection("backward");
onBackToCommunitySetup();
}
: undefined,
clearAvatarDraft: resetAvatarDraft,
importExistingKey: showKeyImportPage,
onUploadingChange: setIsUploadingAvatar,
skipForNow,
submit: () => {
void saveProfileAndContinue(
identityWasImported ? "avatar" : "backup",
);
},
updateAvatarUrl: updateAvatarUrlDraft,
updateDisplayName: updateDisplayNameDraft,
}}
direction={transitionDirection}
state={profileStepState}
/>
) : currentPage === "key-import" ? (
<OnboardingSlideTransition
className="flex w-full flex-col items-center text-center"
className="w-auto"
containerClassName={`fixed bottom-12 left-1/2 z-40 w-auto -translate-x-1/2 ${
hideFixedProgressOnCompact ? "max-lg:hidden" : ""
}`}
direction={transitionDirection}
transitionKey={`key-import-${transitionDirection}`}
transitionKey={`progress-${currentStep}-${transitionDirection}-${
hideFixedProgressOnCompact ? "compact-hidden" : "visible"
}`}
>
<div className="w-full max-w-[440px]">
{identityLost ? (
<StepProgress
activeSegmentClassName="bg-primary"
completeSegmentClassName="bg-primary/35"
currentStep={currentStep}
inactiveSegmentClassName="bg-muted-foreground/25"
totalSteps={totalOnboardingSteps}
/>
</OnboardingSlideTransition>
{membershipError &&
(currentPage === "profile" || currentPage === "avatar") ? (
<div className="mb-4 w-full max-w-[500px] rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm">
{membershipError.kind === "unreachable" ? (
<>
<h1 className="text-3xl font-semibold tracking-tight">
Re-import your key
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Your identity is no longer in the system keyring. Re-import
your nsec to restore it — Buzz will restart to finish
recovery. Or go back to start a new identity with a fresh
key.
<p className="font-medium text-destructive">
Can't reach this relay
</p>
<p className="mt-1 text-muted-foreground">
Check your connection or change your community.
</p>
<Button
className="mt-3"
onClick={() => setIsCommunityChangeOpen(true)}
size="sm"
variant="outline"
>
Change community
</Button>
</>
) : (
<>
<h1 className="text-3xl font-semibold tracking-tight">
Use your existing key
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Import your Nostr private key to use that identity with
Buzz. If this key already has a profile on the relay, your
name and avatar are restored automatically.
<p className="font-medium text-destructive">
{membershipError.message ?? "Something went wrong"}
</p>
<p className="mt-1 text-muted-foreground">
The relay returned an error. Try again.
</p>
</>
)}
</div>
) : null}
{persistError ? (
<p className="mt-4 w-full max-w-[440px] text-sm text-destructive">
{persistError}
</p>
) : null}
<NostrKeyImportForm
backLabel={identityLost ? "Start new identity" : undefined}
onBack={identityLost ? handleLostModeBack : showProfilePage}
onImport={importExistingKey}
{currentPage === "profile" ? (
<ProfileStep
actions={{
advanceWithoutSaving: advanceFromProfileWithoutSaving,
back: () => {
setMembershipError(null);
setIsCommunityChangeOpen(true);
},
clearAvatarDraft: resetAvatarDraft,
importExistingKey: showKeyImportPage,
onUploadingChange: setIsUploadingAvatar,
skipForNow,
submit: () => {
void saveProfileAndContinue(
identityWasImported ? "avatar" : "backup",
);
},
updateAvatarUrl: updateAvatarUrlDraft,
updateDisplayName: updateDisplayNameDraft,
}}
direction={transitionDirection}
state={profileStepState}
/>
</OnboardingSlideTransition>
) : currentPage === "backup" ? (
<BackupStep
currentStep={currentStep}
direction={transitionDirection}
onBack={showProfilePage}
onNext={() => showAvatarPage()}
totalSteps={totalOnboardingSteps}
/>
) : currentPage === "avatar" ? (
<AvatarStep
actions={{
advanceWithoutSaving: () => showThemePage(),
back: identityWasImported ? showProfilePage : showBackupPage,
onUploadingChange: setIsUploadingAvatar,
skipForNow,
submit: () => {
void saveProfileAndContinue("theme");
},
updateAvatarUrl: updateAvatarUrlDraft,
}}
currentStep={currentStep}
direction={transitionDirection}
showAlwaysSkip={true}
state={avatarStepState}
totalSteps={totalOnboardingSteps}
/>
) : currentPage === "theme" ? (
<ThemeStep
actions={{
skip: showSetupPage,
submit: showSetupPage,
}}
direction={transitionDirection}
/>
) : (
<SetupStep
actions={{
back: () => showThemePage("backward"),
complete,
}}
direction={transitionDirection}
/>
)}
) : currentPage === "key-import" ? (
<OnboardingSlideTransition
className="flex w-full flex-col items-center text-center"
direction={transitionDirection}
transitionKey={`key-import-${transitionDirection}`}
>
<div className="w-full max-w-[440px]">
{identityLost ? (
<>
<h1 className="text-3xl font-semibold tracking-tight">
Re-import your key
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Your identity is no longer in the system keyring.
Re-import your nsec to restore it — Buzz will restart to
finish recovery. Or go back to start a new identity with a
fresh key.
</p>
</>
) : (
<>
<h1 className="text-3xl font-semibold tracking-tight">
Use your existing key
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Import your Nostr private key to use that identity with
Buzz. If this key already has a profile on the relay, your
name and avatar are restored automatically.
</p>
</>
)}
</div>
{persistError ? (
<p className="mt-4 w-full max-w-[440px] text-sm text-destructive">
{persistError}
</p>
) : null}
<NostrKeyImportForm
backLabel={identityLost ? "Start new identity" : undefined}
onBack={identityLost ? handleLostModeBack : showProfilePage}
onImport={importExistingKey}
/>
</OnboardingSlideTransition>
) : currentPage === "backup" ? (
<BackupStep
currentStep={currentStep}
direction={transitionDirection}
onBack={showProfilePage}
onNext={() => showAvatarPage()}
totalSteps={totalOnboardingSteps}
/>
) : currentPage === "avatar" ? (
<AvatarStep
actions={{
advanceWithoutSaving: () => showThemePage(),
back: identityWasImported ? showProfilePage : showBackupPage,
onUploadingChange: setIsUploadingAvatar,
skipForNow,
submit: () => {
void saveProfileAndContinue("theme");
},
updateAvatarUrl: updateAvatarUrlDraft,
}}
currentStep={currentStep}
direction={transitionDirection}
showAlwaysSkip={true}
state={avatarStepState}
totalSteps={totalOnboardingSteps}
/>
) : currentPage === "theme" ? (
<ThemeStep
actions={{
skip: showSetupPage,
submit: showSetupPage,
}}
direction={transitionDirection}
/>
) : (
<SetupStep
actions={{
back: () => showThemePage("backward"),
complete,
}}
direction={transitionDirection}
/>
)}
</div>
</div>
</div>
{isCommunityChangeOpen ? (
<CommunityChangeOverlay
onClose={() => setIsCommunityChangeOpen(false)}
/>
) : null}
</>
);
}
+75
View File
@@ -1,5 +1,80 @@
export const INVITE_EXPIRED_ERROR = "invite_expired";
/**
* Parsed invite — either a full (relay + code) or bare-code form.
*
* URL inputs (`https://`, `http://`, `buzz://join`) always carry a
* `relayWsUrl` (already normalised to `ws(s)://`). A bare code (no scheme,
* no slashes) omits it — the caller decides which relay to target.
*/
export type ParsedInvite =
| { relayWsUrl: string; code: string }
| { code: string };
/**
* Parse an invite input into a structured form.
*
* Accepted input forms:
* - `https://<relay>/invite/<code>` → `{ relayWsUrl: "wss://<relay>", code }`
* - `http://<relay>/invite/<code>` → `{ relayWsUrl: "ws://<relay>", code }`
* - `buzz://join?relay=<wsUrl>&code=<code>` → `{ relayWsUrl, code }`
* - bare code (no `://`, no `/`) → `{ code }`
*
* Returns `null` for empty input or inputs that don't match any form.
*/
export function parseInviteInput(input: string): ParsedInvite | null {
const trimmed = input.trim();
if (!trimmed) return null;
// Try URL-form parse first.
try {
const url = new URL(trimmed);
// buzz://join?relay=...&code=...
// Non-special schemes put the authority in `host`, not `pathname`.
if (url.protocol === "buzz:") {
if (url.host !== "join") return null;
const relay = url.searchParams.get("relay");
const code = url.searchParams.get("code");
if (!relay || !code) return null;
if (!relay.startsWith("ws://") && !relay.startsWith("wss://"))
return null;
if (url.username || url.password || url.hash) return null;
// Reject credentials or fragments smuggled inside the nested relay param.
try {
const relayUrl = new URL(relay);
if (relayUrl.username || relayUrl.password || relayUrl.hash)
return null;
} catch {
return null;
}
return { relayWsUrl: relay, code };
}
// https(s)://<relay>/invite/<code>
if (url.protocol === "https:" || url.protocol === "http:") {
if (url.username || url.password || url.hash) return null;
// pathname must be /invite/<code> with optional single trailing slash
const match = url.pathname.match(/^\/invite\/([^/]+)\/?$/);
if (!match?.[1]) return null;
const code = decodeURIComponent(match[1]);
// Convert scheme: https → wss, http → ws. url.host already includes port.
const relayWsUrl =
url.protocol === "https:" ? `wss://${url.host}` : `ws://${url.host}`;
return { relayWsUrl, code };
}
// ws/wss or any other scheme — not an invite URL.
return null;
} catch {
// Not a URL — fall through to bare-code check.
}
// Bare code: no scheme, no slashes.
if (trimmed.includes("://") || trimmed.includes("/")) return null;
return { code: trimmed };
}
/** Convert a ws(s) relay URL to its http(s) equivalent. */
export function relayHttpFromWs(wsUrl: string): string {
if (wsUrl.startsWith("wss://")) return `https://${wsUrl.slice(6)}`;
@@ -0,0 +1,213 @@
/**
* Unit tests for parseInviteInput — the strict invite URL/code parser
* added in Phase 2 (D3). Covers all three plan-specced input forms,
* HTTPS→WSS / HTTP→WS canonicalization, and rejection cases.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { parseInviteInput } from "./inviteHelpers.ts";
// ---------------------------------------------------------------------------
// HTTPS invite URLs → canonical wss:// relay
// ---------------------------------------------------------------------------
test("parseInviteInput_https_invite_url_returns_wss_relay_and_code", () => {
const result = parseInviteInput("https://relay.example.com/invite/abc123");
assert.deepEqual(result, {
relayWsUrl: "wss://relay.example.com",
code: "abc123",
});
});
test("parseInviteInput_http_invite_url_returns_ws_relay_and_code", () => {
const result = parseInviteInput("http://relay.example.com/invite/abc123");
assert.deepEqual(result, {
relayWsUrl: "ws://relay.example.com",
code: "abc123",
});
});
test("parseInviteInput_https_with_port_preserves_port", () => {
const result = parseInviteInput(
"https://relay.example.com:8443/invite/abc123",
);
assert.deepEqual(result, {
relayWsUrl: "wss://relay.example.com:8443",
code: "abc123",
});
});
test("parseInviteInput_https_trailing_slash_tolerated", () => {
const result = parseInviteInput("https://relay.example.com/invite/abc123/");
assert.deepEqual(result, {
relayWsUrl: "wss://relay.example.com",
code: "abc123",
});
});
test("parseInviteInput_https_url_encoded_code_decoded", () => {
const result = parseInviteInput(
"https://relay.example.com/invite/hello%20world",
);
assert.deepEqual(result, {
relayWsUrl: "wss://relay.example.com",
code: "hello world",
});
});
// ---------------------------------------------------------------------------
// buzz://join deep link URLs
// ---------------------------------------------------------------------------
test("parseInviteInput_buzz_join_with_wss_relay_returns_relay_and_code", () => {
const result = parseInviteInput(
"buzz://join?relay=wss://relay.example.com&code=abc123",
);
assert.deepEqual(result, {
relayWsUrl: "wss://relay.example.com",
code: "abc123",
});
});
test("parseInviteInput_buzz_join_with_ws_relay_returns_relay_and_code", () => {
const result = parseInviteInput(
"buzz://join?relay=ws://localhost:3000&code=testcode",
);
assert.deepEqual(result, {
relayWsUrl: "ws://localhost:3000",
code: "testcode",
});
});
test("parseInviteInput_buzz_join_with_encoded_relay_param", () => {
const result = parseInviteInput(
"buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=abc123",
);
assert.deepEqual(result, {
relayWsUrl: "wss://relay.example.com",
code: "abc123",
});
});
test("parseInviteInput_buzz_join_rejects_non_ws_relay", () => {
const result = parseInviteInput(
"buzz://join?relay=https://relay.example.com&code=abc123",
);
assert.equal(result, null);
});
test("parseInviteInput_buzz_join_rejects_missing_code", () => {
assert.equal(
parseInviteInput("buzz://join?relay=wss://relay.example.com"),
null,
);
});
test("parseInviteInput_buzz_join_rejects_missing_relay", () => {
assert.equal(parseInviteInput("buzz://join?code=abc123"), null);
});
// ---------------------------------------------------------------------------
// Bare codes
// ---------------------------------------------------------------------------
test("parseInviteInput_bare_code_returns_code_only", () => {
const result = parseInviteInput("abc123");
assert.deepEqual(result, { code: "abc123" });
});
test("parseInviteInput_bare_code_trims_whitespace", () => {
const result = parseInviteInput(" abc123 ");
assert.deepEqual(result, { code: "abc123" });
});
// ---------------------------------------------------------------------------
// Rejection cases
// ---------------------------------------------------------------------------
test("parseInviteInput_rejects_empty_input", () => {
assert.equal(parseInviteInput(""), null);
assert.equal(parseInviteInput(" "), null);
});
test("parseInviteInput_rejects_credentials_in_https_url", () => {
assert.equal(
parseInviteInput("https://user:pass@relay.example.com/invite/abc123"),
null,
);
});
test("parseInviteInput_rejects_fragment_in_https_url", () => {
assert.equal(
parseInviteInput("https://relay.example.com/invite/abc123#section"),
null,
);
});
test("parseInviteInput_rejects_non_invite_https_pathname", () => {
// /api/invites is not /invite/<code>
assert.equal(
parseInviteInput("https://relay.example.com/api/invites/abc123"),
null,
);
// Root path
assert.equal(parseInviteInput("https://relay.example.com/"), null);
// Missing code segment
assert.equal(parseInviteInput("https://relay.example.com/invite/"), null);
});
test("parseInviteInput_rejects_buzz_join_with_credentials", () => {
assert.equal(
parseInviteInput(
"buzz://user:pass@join?relay=wss://relay.example.com&code=abc123",
),
null,
);
});
test("parseInviteInput_rejects_buzz_join_with_hash", () => {
assert.equal(
parseInviteInput(
"buzz://join?relay=wss://relay.example.com&code=abc123#frag",
),
null,
);
});
test("parseInviteInput_rejects_ws_wss_scheme_urls", () => {
// ws/wss URLs are relay URLs, not invite URLs
assert.equal(parseInviteInput("wss://relay.example.com"), null);
assert.equal(parseInviteInput("ws://relay.example.com"), null);
});
test("parseInviteInput_rejects_input_with_slashes_as_bare_code", () => {
// Slashes suggest a URL but don't parse as a valid invite URL
assert.equal(parseInviteInput("not/a/code"), null);
});
test("parseInviteInput_rejects_input_with_scheme_as_bare_code", () => {
assert.equal(parseInviteInput("ftp://something"), null);
});
// ---------------------------------------------------------------------------
// Nested relay param credential/fragment rejection (MINOR 6 regression)
// ---------------------------------------------------------------------------
test("parseInviteInput_buzz_join_rejects_credentials_in_nested_relay", () => {
assert.equal(
parseInviteInput(
"buzz://join?relay=wss%3A%2F%2Fuser%3Apass%40relay.example.com&code=abc",
),
null,
);
});
test("parseInviteInput_buzz_join_rejects_fragment_in_nested_relay", () => {
assert.equal(
parseInviteInput(
"buzz://join?relay=wss%3A%2F%2Frelay.example.com%23frag&code=abc",
),
null,
);
});
+14
View File
@@ -130,6 +130,10 @@ type E2eConfig = {
channelMembersReadDelayMs?: number;
createManagedAgentDelayMs?: number;
channelsReadError?: string;
/** Reject successive mock `create_channel` calls, then resume. */
createChannelErrors?: string[];
/** Reject successive mock `join_channel` calls, then resume. */
joinChannelErrors?: string[];
channelsReadDelayMs?: number;
/** Number of seeded rows in the deep-history fixture. Defaults to 600. */
deepHistoryMessageCount?: number;
@@ -5293,6 +5297,11 @@ async function handleCreateChannel(
? new Date(Date.now() + args.ttlSeconds * 1_000).toISOString()
: null;
if (!identity) {
const createChannelError = config?.mock?.createChannelErrors?.shift();
if (createChannelError) {
throw new Error(createChannelError);
}
const owner = createCurrentMember(config, "owner");
const channel = createMockChannel({
id: crypto.randomUUID(),
@@ -5989,6 +5998,11 @@ async function handleJoinChannel(
) {
const identity = getIdentity(config);
if (!identity) {
const joinChannelError = config?.mock?.joinChannelErrors?.shift();
if (joinChannelError) {
throw new Error(joinChannelError);
}
const channel = getMockChannel(args.channelId);
const currentPubkey = getMockMemberPubkey(config);
+25
View File
@@ -117,6 +117,31 @@ test("locked boot shows the keyring-locked screen without the onboarding gate or
).toHaveCount(0);
});
test("locked boot can re-import a key and requires relaunch", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLocked: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(page.getByTestId("keyring-locked")).toBeVisible();
page.on("dialog", (dialog) => dialog.accept());
await page
.getByRole("button", { name: "Re-import your key instead" })
.click();
const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey));
await page.getByTestId("nostr-import-nsec-input").fill(importedNsec);
await expect(page.getByTestId("nostr-import-npub-preview")).toBeVisible();
await page.getByTestId("nostr-import-submit").click();
await expect(page.getByTestId("relaunch-required")).toBeVisible();
await expect(page.getByTestId("keyring-locked")).toHaveCount(0);
});
test("locked screen relaunch button records the process-restart invoke", async ({
page,
}) => {
+320
View File
@@ -1032,6 +1032,143 @@ test("first-run onboarding keeps the shell hidden through setup and lands on Wel
await expectWelcomeGuideIntro(page);
});
function retryToast(page: Page, title: string) {
return page
.locator("[data-sonner-toast][data-removed='false']")
.filter({ hasText: title });
}
async function retryToastAction(
page: Page,
{ command, title }: { command: string; title: string },
) {
const activeToast = retryToast(page, title);
await expect(activeToast).toBeVisible();
await expect(
activeToast.getByRole("button", { name: "Retry" }),
).toBeVisible();
const commandCountBeforeRetry = await page.evaluate(
(retryCommand) =>
(
window as Window & {
__BUZZ_E2E_COMMANDS__?: string[];
}
).__BUZZ_E2E_COMMANDS__?.filter((entry) => entry === retryCommand)
.length ?? 0,
command,
);
await activeToast
.getByRole("button", { name: "Retry" })
.evaluate((button) => (button as HTMLButtonElement).click());
await expect
.poll(() =>
page.evaluate(
({ retryCommand }) =>
(
window as Window & {
__BUZZ_E2E_COMMANDS__?: string[];
}
).__BUZZ_E2E_COMMANDS__?.filter((entry) => entry === retryCommand)
.length ?? 0,
{ retryCommand: command, minimum: commandCountBeforeRetry + 1 },
),
)
.toBeGreaterThanOrEqual(commandCountBeforeRetry + 1);
}
async function expectRetryFailureRecreatesActionableToast(
page: Page,
{ command, error, title }: { command: string; error: string; title: string },
) {
const activeToast = retryToast(page, title);
await expect(activeToast).toContainText(error);
await retryToastAction(page, { command, title });
await expect(activeToast).toHaveCount(1);
await expect(activeToast).toContainText(error);
await expect(
activeToast.getByRole("button", { name: "Retry" }),
).toBeVisible();
}
async function expectRetrySuccessDismissesToast(
page: Page,
{ command, title }: { command: string; title: string },
) {
const activeToast = retryToast(page, title);
await retryToastAction(page, { command, title });
await expect(activeToast).toHaveCount(0);
}
test("failed Welcome and general retries recreate actionable toasts", async ({
page,
}) => {
const welcomeError = "Mock Welcome create failed.";
const generalError = "Mock general join failed.";
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
createChannelErrors: [welcomeError, welcomeError],
joinChannelErrors: [generalError, generalError],
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await continueToSetupPage(page);
await page.getByTestId("onboarding-finish").click();
await expectRetryFailureRecreatesActionableToast(page, {
command: "create_channel",
error: welcomeError,
title: "Couldn't set up the Welcome channel",
});
await expectRetryFailureRecreatesActionableToast(page, {
command: "join_channel",
error: generalError,
title: "Couldn't join #general",
});
});
test("successful Welcome and general retries clear their actionable toasts", async ({
page,
}) => {
const welcomeError = "Mock Welcome create failed.";
const generalError = "Mock general join failed.";
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
createChannelErrors: [welcomeError],
joinChannelErrors: [generalError],
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await continueToSetupPage(page);
await page.getByTestId("onboarding-finish").click();
await expectRetrySuccessDismissesToast(page, {
command: "create_channel",
title: "Couldn't set up the Welcome channel",
});
await expectRetrySuccessDismissesToast(page, {
command: "join_channel",
title: "Couldn't join #general",
});
await expectWelcomeView(page);
await expect(page.getByTestId("channel-general")).toBeVisible();
});
test("first-run onboarding shows setup loading until Welcome bootstrap completes", async ({
page,
}) => {
@@ -1537,3 +1674,186 @@ test("onboarding relay reconnect — connected without a prior click does not sh
await expect(card).toContainText("Can't reach the relay");
await expect(card).not.toContainText("Connected");
});
test("membership denied shows all four affordances and change-community edits non-destructively", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
relayRole: null,
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
// Fill the display name and advance — membership check triggers denial.
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
// Membership-denied screen renders with all four affordances.
const denied = page.getByTestId("membership-denied");
await expect(denied).toBeVisible();
await expect(denied.getByRole("button", { name: "Try again" })).toBeVisible();
await expect(denied.getByRole("button", { name: "Back" })).toBeVisible();
await expect(
denied.getByRole("button", { name: "Change community" }),
).toBeVisible();
await expect(page.getByTestId("membership-denied-change-key")).toBeVisible();
await expect(
page.getByTestId("membership-denied-redeem-invite"),
).toBeVisible();
// Click "Change community" → the overlay opens.
await denied.getByRole("button", { name: "Change community" }).click();
const overlay = page.getByTestId("community-change-overlay");
await expect(overlay).toBeVisible();
// Change the relay URL to a new one. The probe will time out for a fake URL
// so we wait for the "Use anyway" button.
await overlay
.locator("#community-edit-url")
.fill("wss://new-relay.example.com");
await overlay.getByRole("button", { name: "Save changes" }).click();
// The fields are frozen while the probe is pending, so the saved URL and
// any warning cannot get out of sync with a subsequent edit.
await expect(overlay.locator("#community-edit-url")).toBeDisabled();
await expect(overlay.locator("#community-edit-name")).toBeDisabled();
await expect(
overlay.getByRole("button", { name: "Use anyway" }),
).toBeVisible();
await overlay.getByRole("button", { name: "Use anyway" }).click();
// The community update triggers a remount (reinitKey bump). The persisted
// community should now point to the new relay URL.
await expect
.poll(() =>
page.evaluate(() => {
const raw = window.localStorage.getItem("buzz-communities");
const communities = raw
? (JSON.parse(raw) as Array<{ relayUrl?: string }>)
: [];
return communities[0]?.relayUrl ?? null;
}),
)
.toBe("wss://new-relay.example.com");
// Identity was NOT wiped — the override storage key is still intact.
await expect
.poll(() =>
page.evaluate((storageKey) => {
return window.localStorage.getItem(storageKey) !== null;
}, E2E_IDENTITY_OVERRIDE_STORAGE_KEY),
)
.toBe(true);
});
test("cancel from profile Back preserves drafts and denied Back returns to interrupted page", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
relayRole: null,
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
// --- Profile step: Back opens community-change overlay ---
const nameInput = page.getByTestId("onboarding-display-name");
await nameInput.fill("Morty QA");
await page.getByTestId("onboarding-back").click();
// The community change overlay should open.
const overlay = page.getByTestId("community-change-overlay");
await expect(overlay).toBeVisible();
// Cancel the overlay — profile should still be visible with the name intact.
await overlay.getByRole("button", { name: "Cancel" }).click();
await expect(overlay).toHaveCount(0);
await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
await expect(nameInput).toHaveValue("Morty QA");
// --- Profile → membership denied → Back returns to profile ---
// Advance triggers membership check → denied (relayRole is null).
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("membership-denied")).toBeVisible();
// Press Back on the denied screen — should return to the profile page
// (deniedFromPage = "profile") with the name draft intact.
await page
.getByTestId("membership-denied")
.getByRole("button", { name: "Back" })
.click();
await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
await expect(nameInput).toHaveValue("Morty QA");
});
test("denied on relay A then paste relay B invite URL switches community to B", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
relayRole: null,
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
// Record the initial relay URL (relay A).
const initialRelayUrl = await page.evaluate(() => {
const raw = window.localStorage.getItem("buzz-communities");
const communities = raw
? (JSON.parse(raw) as Array<{ relayUrl?: string }>)
: [];
return communities[0]?.relayUrl ?? null;
});
expect(initialRelayUrl).not.toBeNull();
// Fill name, advance → denied on relay A.
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("membership-denied")).toBeVisible();
// Intercept the claimInvite POST to relay B so it succeeds.
const relayBUrl = "wss://relay-b.example.com";
const relayBHttpUrl = "https://relay-b.example.com";
await page.route(`${relayBHttpUrl}/api/invites/claim`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
status: "joined",
community_id: "mock-community",
host: "relay-b.example.com",
role: "member",
}),
});
});
// Click "Have an invite?" and enter an HTTPS invite URL for relay B.
await page.getByTestId("membership-denied-redeem-invite").click();
await page
.getByTestId("invite-redeem-input")
.fill(`${relayBHttpUrl}/invite/test-invite-code`);
await page.getByTestId("invite-redeem-submit").click();
// After successful claim, the community should switch to relay B's URL.
await expect
.poll(() =>
page.evaluate(() => {
const raw = window.localStorage.getItem("buzz-communities");
const communities = raw
? (JSON.parse(raw) as Array<{ relayUrl?: string }>)
: [];
return communities[0]?.relayUrl ?? null;
}),
)
.toBe(relayBUrl);
});
+4
View File
@@ -156,6 +156,10 @@ type MockBridgeOptions = {
addChannelMembersDelayMs?: number;
channelMembersReadDelayMs?: number;
channelsReadError?: string;
/** Reject successive mock `create_channel` calls, then resume. */
createChannelErrors?: string[];
/** Reject successive mock `join_channel` calls, then resume. */
joinChannelErrors?: string[];
/** Number of seeded rows in the deep-history fixture. Defaults to 600. */
deepHistoryMessageCount?: number;
feedReadError?: string;