diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index ac92ffee8..6b799636f 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -19,7 +19,10 @@ import { useAppOnboardingState } from "@/features/onboarding/hooks"; import { useMachineOnboardingState } from "@/features/onboarding/machineOnboarding"; import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; import { CommunityOnboardingFlow } from "@/features/onboarding/ui/CommunityOnboardingFlow"; -import { MachineOnboardingFlow } from "@/features/onboarding/ui/MachineOnboardingFlow"; +import { + MachineOnboardingFlow, + type MachineOnboardingPage, +} from "@/features/onboarding/ui/MachineOnboardingFlow"; import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow"; import { PendingInviteGate } from "@/features/onboarding/ui/PendingInviteGate"; import { KeyringLockedScreen } from "@/features/onboarding/ui/KeyringLockedScreen"; @@ -249,7 +252,13 @@ function AppReady({ ); } -function CommunityApp({ sharedIdentity }: { sharedIdentity: boolean }) { +function CommunityApp({ + onBackToMachineConfig, + sharedIdentity, +}: { + onBackToMachineConfig: () => void; + sharedIdentity: boolean; +}) { const { activeCommunity, reinitKey, @@ -325,7 +334,12 @@ function CommunityApp({ sharedIdentity }: { sharedIdentity: boolean }) { // Show welcome setup for first-run users with no communities if (community.needsSetup) { - return ; + return ( + + ); } // Surface apply failures so the user can retry or change community. @@ -391,6 +405,21 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { hasConfiguredCommunity: activeCommunity !== null, isSharedIdentity: sharedIdentity, }); + const [machineInitialPage, setMachineInitialPage] = + useState(); + + const reopenMachineConfig = useCallback(() => { + setMachineInitialPage("config"); + machine.reopen(); + }, [machine.reopen]); + + const completeMachineOnboarding = useCallback( + (pubkey?: string) => { + setMachineInitialPage(undefined); + machine.complete(pubkey); + }, + [machine.complete], + ); // Deep links are captured here — above the machine-onboarding gate — not in // CommunityApp. The Rust side queues them; draining into the persisted @@ -413,7 +442,12 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { if (machine.stage === "relaunch-required") return ; if (machine.stage === "blocking") return ; if (machine.stage === "ready") { - return ; + return ( + + ); } // A community deep link that arrived before machine onboarding finished is @@ -428,8 +462,9 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { return ( <> {shouldAcknowledgeDeepLink ? : null} diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx index d6a688842..a7f7218d9 100644 --- a/desktop/src/features/communities/ui/WelcomeSetup.tsx +++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx @@ -1,13 +1,22 @@ import * as React from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; -import { Check, Copy } from "lucide-react"; +import { Check, Copy, Info } from "lucide-react"; import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; import { InviteRedeemForm } from "@/features/onboarding/ui/InviteRedeemForm"; +import { + ONBOARDING_KEY_FRAME_CLASS, + ONBOARDING_KEY_ROW_CLASS, + ONBOARDING_KEY_TEXT_CLASS, +} from "@/features/onboarding/ui/NsecMaskedDisplay"; import { type OnboardingTransitionDirection, OnboardingSlideTransition, } from "@/features/onboarding/ui/OnboardingSlideTransition"; +import { + OnboardingFooter, + OnboardingFooterProvider, +} from "@/features/onboarding/ui/OnboardingFooter"; import { getIdentity } from "@/shared/api/tauriIdentity"; import { pubkeyToNpub } from "@/shared/lib/nostrUtils"; import { Button } from "@/shared/ui/button"; @@ -21,6 +30,7 @@ type WelcomeTransitionMode = "initial" | OnboardingTransitionDirection; type WelcomeSetupProps = { defaultRelayUrl: string; initialTransitionMode?: WelcomeTransitionMode; + onBack: () => void; }; const CREATE_COMMUNITY_URL = "https://buzz.xyz"; @@ -28,6 +38,8 @@ const LOCAL_DEV_RELAY_URLS = new Set([ "ws://localhost:3000", "ws://127.0.0.1:3000", ]); +const COMMUNITY_OPTION_CARD_CLASS = + "flex min-h-24 w-full max-w-[352px] items-center justify-center rounded-xl bg-white/75 px-6 py-4 text-center text-sm font-normal leading-6 text-foreground transition-colors duration-150 ease-out hover:bg-white/85 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-foreground/35"; function isLocalDevRelayUrl(relayUrl: string) { return LOCAL_DEV_RELAY_URLS.has(relayUrl.trim().replace(/\/$/, "")); @@ -36,6 +48,7 @@ function isLocalDevRelayUrl(relayUrl: string) { export function WelcomeSetup({ defaultRelayUrl, initialTransitionMode = "initial", + onBack, }: WelcomeSetupProps) { const [page, setPage] = React.useState("welcome"); const [transitionMode, setTransitionMode] = @@ -65,13 +78,6 @@ export function WelcomeSetup({ setPage(nextPage); }, []); - const handleDefaultCommunity = React.useCallback(() => { - communityOnboarding.start({ - source: "first-community", - relayUrl: defaultRelayUrl, - }); - }, [communityOnboarding, defaultRelayUrl]); - const handleInviteRedeem = React.useCallback( (relayWsUrl: string, code: string, policyReceipt?: string) => { communityOnboarding.start({ @@ -91,146 +97,178 @@ export function WelcomeSetup({ return (
-
- {page === "welcome" ? ( - -
-

- Join or create a community -

-

- Request access to an existing community or create a new one. -

-
-
- {isLocalDevRelayUrl(defaultRelayUrl) ? null : ( + +
+ {page === "welcome" ? ( + +
+

+ Join or create a community +

+

+ Choose how you’d like to get started. If you have an invite + link, you can open it directly to continue setup. +

+
+
+ - )} - - - -
-
- ) : page === "join" ? ( - -
-

Join a community

-

- Send your public key to a community owner. Keep Buzz open; once - they add you, their invite link will continue setup here. -

-
-

- Your public key (npub) + +

+ + + + + ) : page === "join" ? ( + +
+

+ Request access to community +

+

+ Ask the community host to send you an invite link or add you + directly using your public key.

-
- - {npub || "Loading…"} - -
+
+
+
- {copied ? : } - +
+
+ + {npub || "Loading…"} + +
+ +
+
+ {identityError ? ( +

+ {identityError} +

+ ) : ( +

+ + + This is safe to share. It does not reveal your private + key. + +

+ )}
- {identityError ? ( -

{identityError}

- ) : ( -

- This is safe to share. It does not reveal your private key. -

- )}
- -
-
- ) : ( - -
-

I have an invite link

-

- Keep this page open, then click the invite link you received. - Buzz will continue automatically. You can also paste it below. -

-
-
- showPage("welcome")} - onRedeem={handleInviteRedeem} - /> -
-
- )} -
+ + + +
+ ) : ( + +
+

+ Enter your invite link +

+

+ If you have an invite link for a community, paste it below to + continue setup. +

+
+
+ showPage("welcome")} + onRedeem={handleInviteRedeem} + variant="onboarding-spotlight" + /> +
+
+ )} +
+
); } diff --git a/desktop/src/features/onboarding/machineOnboarding.ts b/desktop/src/features/onboarding/machineOnboarding.ts index e858d4cff..7a34d7cf7 100644 --- a/desktop/src/features/onboarding/machineOnboarding.ts +++ b/desktop/src/features/onboarding/machineOnboarding.ts @@ -28,6 +28,13 @@ export function readMachineOnboardingCompletion(pubkey: string | null) { ); } +function clearMachineOnboardingCompletion(pubkey: string | null) { + if (typeof window === "undefined" || !pubkey) return; + window.localStorage.removeItem( + completionKey(MACHINE_ONBOARDING_COMPLETION_STORAGE_KEY, pubkey), + ); +} + function forceMachineOnboarding() { if (!import.meta.env.DEV || typeof window === "undefined") return false; return ( @@ -150,6 +157,12 @@ export function useMachineOnboardingState({ [currentPubkey], ); + const reopen = React.useCallback(() => { + clearMachineOnboardingCompletion(currentPubkey); + setCompletedPubkey((pubkey) => (pubkey === currentPubkey ? null : pubkey)); + setEvaluatedPubkey(currentPubkey); + }, [currentPubkey]); + const relaunchRequired = ((bootedLost && !identityLost) || (bootedLocked && !identityLocked)) && identityQuery.status === "success"; @@ -189,6 +202,7 @@ export function useMachineOnboardingState({ currentPubkey, identityLost, queryClient, + reopen, stage, }; } diff --git a/desktop/src/features/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx index d88e15137..5fcd84655 100644 --- a/desktop/src/features/onboarding/ui/BackupStep.tsx +++ b/desktop/src/features/onboarding/ui/BackupStep.tsx @@ -10,7 +10,10 @@ import { type OnboardingTransitionDirection, OnboardingSlideTransition, } from "./OnboardingSlideTransition"; -import { NsecMaskedDisplay } from "./NsecMaskedDisplay"; +import { + NsecMaskedDisplay, + ONBOARDING_KEY_FRAME_CLASS, +} from "./NsecMaskedDisplay"; /** * Pure helper so the disabled logic can be unit-tested without a DOM. @@ -123,7 +126,7 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
) : nsec ? ( // Translucent white card frames the key with equal padding. -
+
) : ( diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index 8fef78e25..7b1aa17de 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -19,13 +19,17 @@ import { listPersonas } from "@/shared/api/tauriPersonas"; import type { AgentPersona } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; -import { Input } from "@/shared/ui/input"; import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; import { ONBOARDING_PRIMARY_CTA_CLASS, OnboardingChrome, } from "./OnboardingChrome"; import { OnboardingFooter, OnboardingFooterProvider } from "./OnboardingFooter"; +import { + ONBOARDING_KEY_FRAME_CLASS, + ONBOARDING_KEY_ROW_CLASS, + ONBOARDING_KEY_TEXT_CLASS, +} from "./NsecMaskedDisplay"; const STARTER_PERSONA_ANIMATIONS: Record = { Fizz: "/onboarding/starter-team/fizz.png", @@ -55,7 +59,7 @@ function AvatarCircle({ return ( + diff --git a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx index fc9e9cde7..7013cff3e 100644 --- a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx +++ b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx @@ -11,10 +11,18 @@ import { isJoinPolicyDiscoveryCandidate, type JoinPolicy, } from "@/shared/api/invites"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; import { Spinner } from "@/shared/ui/spinner"; import { JoinPolicyNotice } from "./JoinPolicyNotice"; +import { + ONBOARDING_KEY_FRAME_CLASS, + ONBOARDING_KEY_ROW_CLASS, + ONBOARDING_KEY_TEXT_CLASS, +} from "./NsecMaskedDisplay"; +import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome"; +import { OnboardingFooter } from "./OnboardingFooter"; const POLICY_DISCOVERY_DELAY_MS = 250; const POLICY_REVEAL_EASE = [0.23, 1, 0.32, 1] as const; @@ -31,6 +39,7 @@ type InviteRedeemFormProps = { isRedeeming: boolean; onCancel: () => void; onRedeem: (relayWsUrl: string, code: string, policyReceipt?: string) => void; + variant?: "default" | "onboarding-spotlight"; }; export function InviteRedeemForm({ @@ -39,7 +48,9 @@ export function InviteRedeemForm({ isRedeeming, onCancel, onRedeem, + variant = "default", }: InviteRedeemFormProps) { + const formId = React.useId(); const [inviteInput, setInviteInput] = React.useState(""); const [bareCodeRelayUrl, setBareCodeRelayUrl] = React.useState( defaultRelayUrl ?? "", @@ -96,6 +107,7 @@ export function InviteRedeemForm({ parsed !== null && ("relayWsUrl" in parsed || (isBareCode && bareCodeRelayUrl.trim().length > 0)); + const isOnboardingSpotlight = variant === "onboarding-spotlight"; const handleSubmit = React.useCallback( async (event: React.FormEvent) => { @@ -164,40 +176,147 @@ export function InviteRedeemForm({ ], ); + const handleInviteInputChange = ( + event: React.ChangeEvent, + ) => { + setInviteInput(event.target.value); + setJoinPolicy(null); + setPolicyInvite(null); + setAgeConfirmed(false); + setAgreementConfirmed(false); + setPolicyError(null); + }; + + const handleRelayInputChange = ( + event: React.ChangeEvent, + ) => { + setBareCodeRelayUrl(event.target.value); + setJoinPolicy(null); + setPolicyInvite(null); + setAgeConfirmed(false); + setAgreementConfirmed(false); + setPolicyError(null); + }; + + const submitButton = ( + + ); + + const cancelButton = ( + + ); + return ( -
-
+ + {isOnboardingSpotlight ? ( - { - setInviteInput(event.target.value); - setJoinPolicy(null); - setPolicyInvite(null); - setAgeConfirmed(false); - setAgreementConfirmed(false); - setPolicyError(null); - }} - placeholder="https://relay.example.com/invite/abc123 or paste a code" - spellCheck={false} - type="text" - value={inviteInput} - /> -
+ ) : ( +
+ + +
+ )} {needsRelayField ? ( -
+