diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 93fb836739..cb09cbdc5f 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -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 (
- -
-
- -

- Welcome to Buzz -

-

- Choose your first community to get started. -

- -
- - - - - -
-
+

+ {connectingHeading} +

+ +

+ Connecting to your community… +

+
+ + ) : ( + + Buzz + +

+ Welcome to Buzz +

+

+ Choose your first community to get started. +

+ +
+ + + + + + + +
+
+ )} ); @@ -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 ; @@ -322,8 +370,8 @@ function AppReady({ } if (onboarding.stage === "blocking") { - if (isCompletingFirstRunCommunity) { - return ; + if (firstRunHandoffSource !== null) { + return ; } return isCommunitySwitch ? : ; @@ -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(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 ; + if (firstRunHandoffSource !== null) { + return ; } return isCommunitySwitch ? : ; @@ -466,12 +516,12 @@ export function App() { const showBootSplashOverlay = bootSplashPhase !== "done" && !isCommunitySwitch && - !isCompletingFirstRunCommunity; + firstRunHandoffSource === null; return ( , + 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({ >

- Use your existing key + {WELCOME_SETUP_PAGE_HEADINGS["nostr-key"]}

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({ >

- Join a community + {WELCOME_SETUP_PAGE_HEADINGS["create-community"]}

Communities are where teammates and agents collaborate across @@ -362,7 +379,7 @@ export function WelcomeSetup({ >

- Redeem an invite + {WELCOME_SETUP_PAGE_HEADINGS.invite}

Paste an invite link or code from a relay admin to join their diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 6ee97dba43..bbda990706 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -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, }) => {