fix(desktop): variant-aware connecting gate for first-run handoffs (#1938)

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Matt Toohey
2026-07-16 14:26:33 +10:00
committed by GitHub
parent 2910ea45de
commit dd39b6249f
3 changed files with 192 additions and 76 deletions

View File

@@ -2,7 +2,6 @@ import { isTauri } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router";
import { Hexagon } from "lucide-react";
import {
type ReactNode,
useCallback,
@@ -26,7 +25,11 @@ import type { Community } from "@/features/communities/types";
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 {
WELCOME_SETUP_PAGE_HEADINGS,
WelcomeSetup,
type WelcomeSetupPage,
} 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";
@@ -178,8 +181,17 @@ function CommunitySwitchGate() {
);
}
function OnboardingLoadingGate() {
// Shown while a first-run community handoff settles (config apply + relay
// round trips). For the default-community path the user pressed a button on
// the welcome page itself, so the gate keeps rendering that same page with
// the buttons disabled — seamless continuity. Every other WelcomeSetup
// sub-page (key import, invite, join community) instead gets a forward-motion
// connecting view under its own heading; showing the welcome replica there
// reads as being kicked back to step 1.
function OnboardingLoadingGate({ source }: { source: WelcomeSetupPage }) {
const systemColorScheme = useSystemColorScheme();
const connectingHeading =
source !== "welcome" ? WELCOME_SETUP_PAGE_HEADINGS[source] : null;
return (
<div
@@ -196,55 +208,95 @@ function OnboardingLoadingGate() {
inactiveSegmentClassName="bg-muted-foreground/25"
/>
<OnboardingSlideTransition
className="flex w-full flex-col items-center text-center"
direction="forward"
effect="none"
transitionKey="community-connecting"
>
<div className="flex h-14 w-14 items-center justify-center rounded-lg border border-border bg-background text-foreground shadow-xs">
<Hexagon className="h-7 w-7" aria-hidden="true" />
</div>
<h1 className="mt-6 text-3xl font-semibold tracking-tight">
Welcome to Buzz
</h1>
<p className="mt-3 max-w-[440px] text-sm leading-6 text-muted-foreground">
Choose your first community to get started.
</p>
<div className="mt-8 flex w-full max-w-[500px] flex-col gap-3">
<Button
aria-disabled="true"
className="h-10 w-full"
tabIndex={-1}
type="button"
{connectingHeading !== null ? (
<OnboardingSlideTransition
className="flex w-full flex-col items-center text-center"
direction="forward"
effect="none"
transitionKey="community-connecting"
>
<div
className="flex w-full max-w-[440px] flex-col items-center"
data-testid="onboarding-connecting-gate"
role="status"
>
Continue with default community
</Button>
<h1 className="text-3xl font-semibold tracking-tight">
{connectingHeading}
</h1>
<BeeLoader
ariaLabel="Connecting to your community…"
className="mt-12 h-auto w-20"
tintClassName="text-muted-foreground"
/>
<p className="mt-6 text-sm leading-6 text-muted-foreground">
Connecting to your community…
</p>
</div>
</OnboardingSlideTransition>
) : (
<OnboardingSlideTransition
className="flex w-full flex-col items-center text-center"
direction="forward"
effect="none"
transitionKey="community-connecting"
>
<img
alt="Buzz"
className="h-14 w-14 rounded-xl shadow-xs"
src="/app-icon@2x.png"
srcSet="/app-icon@2x.png 1x, /app-icon@3x.png 2x"
/>
<Button
aria-disabled="true"
className="h-10 w-full"
tabIndex={-1}
type="button"
variant="secondary"
>
Join a community
</Button>
<h1 className="mt-6 text-3xl font-semibold tracking-tight">
Welcome to Buzz
</h1>
<p className="mt-3 max-w-[440px] text-sm leading-6 text-muted-foreground">
Choose your first community to get started.
</p>
<Button
aria-disabled="true"
className="h-10 w-full"
data-testid="welcome-continue-nostr"
tabIndex={-1}
type="button"
variant="ghost"
>
I already have a key
</Button>
</div>
</OnboardingSlideTransition>
<div className="mt-8 flex w-full max-w-[500px] flex-col gap-3">
<Button
aria-disabled="true"
className="h-10 w-full"
tabIndex={-1}
type="button"
>
Continue with default community
</Button>
<Button
aria-disabled="true"
className="h-10 w-full"
tabIndex={-1}
type="button"
variant="secondary"
>
Join a community
</Button>
<Button
aria-disabled="true"
className="h-10 w-full"
tabIndex={-1}
type="button"
variant="ghost"
>
Have an invite?
</Button>
<Button
aria-disabled="true"
className="h-10 w-full"
data-testid="welcome-continue-nostr"
tabIndex={-1}
type="button"
variant="ghost"
>
I already have a key
</Button>
</div>
</OnboardingSlideTransition>
)}
</div>
</div>
);
@@ -276,12 +328,12 @@ function CommunityQueryProvider({ children }: { children: ReactNode }) {
}
function AppReady({
isCompletingFirstRunCommunity,
firstRunHandoffSource,
isSharedIdentity,
isCommunitySwitch,
onFirstRunCommunitySettled,
}: {
isCompletingFirstRunCommunity: boolean;
firstRunHandoffSource: WelcomeSetupPage | null;
isSharedIdentity: boolean;
isCommunitySwitch: boolean;
onFirstRunCommunitySettled: () => void;
@@ -289,14 +341,10 @@ function AppReady({
const onboarding = useAppOnboardingState(isSharedIdentity);
useEffect(() => {
if (isCompletingFirstRunCommunity && onboarding.stage !== "blocking") {
if (firstRunHandoffSource !== null && onboarding.stage !== "blocking") {
onFirstRunCommunitySettled();
}
}, [
isCompletingFirstRunCommunity,
onboarding.stage,
onFirstRunCommunitySettled,
]);
}, [firstRunHandoffSource, onboarding.stage, onFirstRunCommunitySettled]);
if (onboarding.stage === "reset-failed") {
return <ResetFailedScreen />;
@@ -322,8 +370,8 @@ function AppReady({
}
if (onboarding.stage === "blocking") {
if (isCompletingFirstRunCommunity) {
return <OnboardingLoadingGate />;
if (firstRunHandoffSource !== null) {
return <OnboardingLoadingGate source={firstRunHandoffSource} />;
}
return isCommunitySwitch ? <CommunitySwitchGate /> : <AppLoadingGate />;
@@ -359,8 +407,10 @@ export function App() {
switchCommunity,
reconnectCommunity,
} = useCommunities();
const [isCompletingFirstRunCommunity, setIsCompletingFirstRunCommunity] =
useState(false);
// Non-null while a first-run community handoff settles; the value is the
// WelcomeSetup sub-page it started from, which picks the loading gate.
const [firstRunHandoffSource, setFirstRunHandoffSource] =
useState<WelcomeSetupPage | null>(null);
const [isCommunityChangeOpen, setIsCommunityChangeOpen] = useState(false);
useEffect(() => {
@@ -399,8 +449,8 @@ export function App() {
);
const handleSetupComplete = useCallback(
(community: Community) => {
setIsCompletingFirstRunCommunity(true);
(community: Community, source: WelcomeSetupPage) => {
setFirstRunHandoffSource(source);
const communityId = addCommunity(community);
switchCommunity(communityId);
},
@@ -408,7 +458,7 @@ export function App() {
);
const handleFirstRunCommunitySettled = useCallback(() => {
setIsCompletingFirstRunCommunity(false);
setFirstRunHandoffSource(null);
}, []);
const bootSplashPhase = useBootSplashHold();
@@ -453,8 +503,8 @@ export function App() {
// a one-render race where React sees the new active community while the Tauri
// backend is still configured for the previous one.
if (!community.isReady || community.appliedKey !== communityKey) {
if (isCompletingFirstRunCommunity) {
return <OnboardingLoadingGate />;
if (firstRunHandoffSource !== null) {
return <OnboardingLoadingGate source={firstRunHandoffSource} />;
}
return isCommunitySwitch ? <CommunitySwitchGate /> : <AppLoadingGate />;
@@ -466,12 +516,12 @@ export function App() {
const showBootSplashOverlay =
bootSplashPhase !== "done" &&
!isCommunitySwitch &&
!isCompletingFirstRunCommunity;
firstRunHandoffSource === null;
return (
<CommunityQueryProvider key={communityKey}>
<AppReady
isCompletingFirstRunCommunity={isCompletingFirstRunCommunity}
firstRunHandoffSource={firstRunHandoffSource}
isCommunitySwitch={isCommunitySwitch}
key={communityKey}
isSharedIdentity={sharedIdentity}

View File

@@ -22,13 +22,29 @@ import type { Community } from "../types";
import { initFirstCommunity } from "../communityStorage";
import { CommunityEditForm } from "./CommunityEditForm";
type WelcomeSetupPage = "welcome" | "create-community" | "invite" | "nostr-key";
export type WelcomeSetupPage =
| "welcome"
| "create-community"
| "invite"
| "nostr-key";
// Sub-page headings, also rendered by the first-run connecting gate in
// App.tsx so the gate always matches the page the handoff started from.
export const WELCOME_SETUP_PAGE_HEADINGS: Record<
Exclude<WelcomeSetupPage, "welcome">,
string
> = {
"create-community": "Join a community",
invite: "Redeem an invite",
"nostr-key": "Use your existing key",
};
type WelcomeTransitionMode = "initial" | OnboardingTransitionDirection;
type WelcomeSetupProps = {
defaultRelayUrl: string;
initialTransitionMode?: WelcomeTransitionMode;
onComplete: (community: Community) => void;
onComplete: (community: Community, source: WelcomeSetupPage) => void;
};
const DEFAULT_COMMUNITY_HANDOFF_MIN_MS = 200;
@@ -66,7 +82,7 @@ function NostrKeyImportPage({
>
<div className="w-full max-w-[440px]">
<h1 className="text-3xl font-semibold tracking-tight">
Use your existing key
{WELCOME_SETUP_PAGE_HEADINGS["nostr-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
@@ -138,8 +154,9 @@ export function WelcomeSetup({
}
// The parent moves this community into React state so first-run setup
// can continue without a full page reload.
onComplete(community);
// can continue without a full page reload. The source page lets the
// parent's loading gate keep matching the page the user came from.
onComplete(community, page);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to connect. Try again.",
@@ -147,7 +164,7 @@ export function WelcomeSetup({
setIsConnecting(false);
}
},
[onComplete],
[onComplete, page],
);
const handleNostrImport = React.useCallback(
@@ -327,7 +344,7 @@ export function WelcomeSetup({
>
<div className="w-full max-w-[440px]">
<h1 className="text-3xl font-semibold tracking-tight">
Join a community
{WELCOME_SETUP_PAGE_HEADINGS["create-community"]}
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Communities are where teammates and agents collaborate across
@@ -362,7 +379,7 @@ export function WelcomeSetup({
>
<div className="w-full max-w-[440px]">
<h1 className="text-3xl font-semibold tracking-tight">
Redeem an invite
{WELCOME_SETUP_PAGE_HEADINGS.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

View File

@@ -588,6 +588,7 @@ test("first-run default community handoff gives immediate stepper feedback", asy
);
await page.waitForTimeout(240);
await expect(page.getByTestId("welcome-continue-nostr")).toBeVisible();
await expect(page.getByTestId("onboarding-connecting-gate")).toHaveCount(0);
await expect(page.getByRole("progressbar")).toHaveAttribute(
"aria-valuenow",
"2",
@@ -638,6 +639,54 @@ test("welcome can continue using an existing Nostr key", async ({ page }) => {
await expectHomeView(page);
});
test("key import handoff shows a connecting gate, not the welcome replica", async ({
page,
}) => {
// Delay the profile read so the post-import handoff gate stays on screen
// long enough to assert its contents.
await installMockBridge(
page,
{ profileReadDelayMs: 2_000 },
{
relayWsUrl: "wss://default.example.com",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
);
await page.goto("/");
await page.getByTestId("welcome-continue-nostr").click();
await expect(
page.getByRole("heading", { name: "Use your existing key" }),
).toBeVisible();
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();
// The handoff gate reads as the key-import page still loading: same
// heading with forward-motion connecting copy — never the step-1 welcome
// replica, which looks like being kicked back to the start.
const connectingGate = page.getByTestId("onboarding-connecting-gate");
await expect(connectingGate).toBeVisible();
await expect(
connectingGate.getByRole("heading", { name: "Use your existing key" }),
).toBeVisible();
await expect(connectingGate).toContainText("Connecting to your community…");
await expect(page.getByRole("progressbar")).toHaveAttribute(
"aria-valuenow",
"2",
);
await expect(page.getByText("Choose your first community")).toHaveCount(0);
await expect(page.getByTestId("welcome-continue-nostr")).toHaveCount(0);
// Alice already has a relay profile with a display name, so onboarding
// auto-completes into the app once the handoff settles.
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expectHomeView(page);
});
test("welcome presents custom community setup as joining a community", async ({
page,
}) => {