mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): remediate all onboarding dead paths (D1-D7) (#1856)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
+21
-23
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user