mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
Co-authored-by: Fizz <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
488 lines
16 KiB
TypeScript
488 lines
16 KiB
TypeScript
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,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
|
|
import { router } from "@/app/router";
|
|
import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground";
|
|
import { useReloadShortcut } from "@/app/useReloadShortcut";
|
|
import { useAppOnboardingState } from "@/features/onboarding/hooks";
|
|
import { OnboardingSlideTransition } from "@/features/onboarding/ui/OnboardingSlideTransition";
|
|
import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow";
|
|
import { KeyringLockedScreen } from "@/features/onboarding/ui/KeyringLockedScreen";
|
|
import { RelaunchRequiredScreen } from "@/features/onboarding/ui/RelaunchRequiredScreen";
|
|
import type { Workspace } from "@/features/workspaces/types";
|
|
import { useWorkspaceInit } from "@/features/workspaces/useWorkspaceInit";
|
|
import { useNestNotifications } from "@/features/workspaces/useNestNotifications";
|
|
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
|
|
import { WelcomeSetup } from "@/features/workspaces/ui/WelcomeSetup";
|
|
import { createBuzzQueryClient } from "@/shared/api/queryClient";
|
|
import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri";
|
|
import { listenForDeepLinks } from "@/shared/deep-link";
|
|
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
|
|
import { cn } from "@/shared/lib/cn";
|
|
import { Button } from "@/shared/ui/button";
|
|
import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
|
|
import { FlappingBee } from "@/shared/ui/buzz-logo/FlappingBee";
|
|
import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo";
|
|
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
|
|
import { StepProgress } from "@/shared/ui/step-progress";
|
|
|
|
const LOADING_TEXT = "Setting up your workspace...";
|
|
|
|
// Minimum time the cold-boot splash stays on screen. A real boot resolves the
|
|
// workspace in well under 100ms, and the native window setup plus first paint
|
|
// can take longer than that — without a hold, the bee is unmounted before it is
|
|
// ever visible. The hold runs as an overlay above the already-mounted app, so
|
|
// time-to-interactive is unchanged; only the reveal waits.
|
|
const BOOT_SPLASH_MIN_VISIBLE_MS = 1_200;
|
|
const BOOT_SPLASH_FADE_MS = 200;
|
|
const INITIAL_RENDER_READY_EVENT = "initial-render-ready";
|
|
|
|
type BootSplashPhase = "holding" | "fading" | "done";
|
|
|
|
function useInitialRenderReady() {
|
|
useLayoutEffect(() => {
|
|
if (!isTauri()) {
|
|
return;
|
|
}
|
|
|
|
void emit(INITIAL_RENDER_READY_EVENT);
|
|
}, []);
|
|
}
|
|
|
|
// E2E runs skip the hold (it would slow every spec's boot and block pointer
|
|
// actionability); a spec can opt back in via __BUZZ_E2E__.bootSplashHoldMs.
|
|
function bootSplashHoldMs(): number {
|
|
const e2e = (
|
|
window as Window & {
|
|
__BUZZ_E2E__?: { bootSplashHoldMs?: number };
|
|
}
|
|
).__BUZZ_E2E__;
|
|
if (e2e) {
|
|
return e2e.bootSplashHoldMs ?? 0;
|
|
}
|
|
return BOOT_SPLASH_MIN_VISIBLE_MS;
|
|
}
|
|
|
|
function useBootSplashHold(): BootSplashPhase {
|
|
const [phase, setPhase] = useState<BootSplashPhase>(() =>
|
|
bootSplashHoldMs() > 0 ? "holding" : "done",
|
|
);
|
|
|
|
useEffect(() => {
|
|
const holdMs = bootSplashHoldMs();
|
|
if (holdMs <= 0) {
|
|
return;
|
|
}
|
|
const fadeTimer = window.setTimeout(() => setPhase("fading"), holdMs);
|
|
const doneTimer = window.setTimeout(
|
|
() => setPhase("done"),
|
|
holdMs + BOOT_SPLASH_FADE_MS,
|
|
);
|
|
return () => {
|
|
window.clearTimeout(fadeTimer);
|
|
window.clearTimeout(doneTimer);
|
|
};
|
|
}, []);
|
|
|
|
return phase;
|
|
}
|
|
|
|
// Animated Buzz mark for the loading gates. The static BuzzMark renders in
|
|
// normal flow and sizes the box — it's plain SVG (no JS/SMIL), so it paints on
|
|
// the very first frame even before scripting starts, avoiding a blank flash on
|
|
// hard reload. The animated FuzzyLogo is layered on top and takes over once it
|
|
// begins playing.
|
|
function BeeLoader({
|
|
ariaLabel,
|
|
className,
|
|
tintClassName = "text-foreground",
|
|
}: {
|
|
ariaLabel: string;
|
|
className?: string;
|
|
tintClassName?: string;
|
|
}) {
|
|
return (
|
|
<div className={cn("relative", tintClassName, className)}>
|
|
<BuzzMark className="block h-auto w-full" />
|
|
<FuzzyLogo
|
|
ariaLabel={ariaLabel}
|
|
className="absolute inset-0 h-full! w-full! [&>svg]:h-full [&>svg]:w-full [&>svg]:max-w-full"
|
|
fuzz
|
|
loop
|
|
loopRestSeconds={0}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Cold boot gate: the theme-adaptive grainient background with a single
|
|
// centered Buzz bee flying over it — the same static mark as before, now with
|
|
// its wings flapping (ported from the Buzz website's wing-flap). Replaces the
|
|
// old "Setting up your workspace" text, which stays as an sr-only caption.
|
|
function AppLoadingGate() {
|
|
return (
|
|
<div
|
|
className="buzz-setup-loading-shell flex min-h-dvh flex-col items-center justify-center overflow-hidden px-6 py-10"
|
|
data-testid="app-loading-gate"
|
|
role="status"
|
|
>
|
|
<StartupWindowDragRegion />
|
|
<ThemeGrainientBackground />
|
|
<span className="sr-only">{LOADING_TEXT}</span>
|
|
<FlappingBee className="relative z-10 h-auto w-28" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Quiet gate for switching between already-set-up workspaces: visually empty
|
|
// unless the switch takes long, so fast switches don't flash the boot splash.
|
|
function WorkspaceSwitchGate() {
|
|
const [showSpinner, setShowSpinner] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const timer = window.setTimeout(() => setShowSpinner(true), 300);
|
|
return () => window.clearTimeout(timer);
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
className="flex min-h-dvh items-center justify-center bg-background"
|
|
data-testid="workspace-switch-gate"
|
|
role="status"
|
|
>
|
|
<StartupWindowDragRegion />
|
|
<span className="sr-only">Switching workspace…</span>
|
|
{showSpinner ? (
|
|
<BeeLoader
|
|
ariaLabel="Switching workspace…"
|
|
className="h-auto w-20"
|
|
tintClassName="text-muted-foreground"
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function OnboardingLoadingGate() {
|
|
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}
|
|
>
|
|
<StartupWindowDragRegion />
|
|
<div className="relative flex w-full max-w-[500px] flex-col items-center text-center">
|
|
<StepProgress
|
|
activeSegmentClassName="bg-primary"
|
|
className="fixed bottom-12 left-1/2 z-40 -translate-x-1/2"
|
|
completeSegmentClassName="bg-primary/35"
|
|
currentStep={2}
|
|
inactiveSegmentClassName="bg-muted-foreground/25"
|
|
/>
|
|
|
|
<OnboardingSlideTransition
|
|
className="flex w-full flex-col items-center text-center"
|
|
direction="forward"
|
|
effect="none"
|
|
transitionKey="workspace-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 workspace 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"
|
|
>
|
|
Continue with default workspace
|
|
</Button>
|
|
|
|
<Button
|
|
aria-disabled="true"
|
|
className="h-10 w-full"
|
|
tabIndex={-1}
|
|
type="button"
|
|
variant="secondary"
|
|
>
|
|
Join a workspace
|
|
</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>
|
|
);
|
|
}
|
|
|
|
function WorkspaceQueryProvider({ children }: { children: ReactNode }) {
|
|
const [queryClient] = useState(createBuzzQueryClient);
|
|
|
|
useEffect(() => {
|
|
const e2eWindow = window as Window & {
|
|
__BUZZ_E2E__?: unknown;
|
|
__BUZZ_E2E_QUERY_CLIENT__?: typeof queryClient;
|
|
};
|
|
if (!e2eWindow.__BUZZ_E2E__) {
|
|
return;
|
|
}
|
|
|
|
e2eWindow.__BUZZ_E2E_QUERY_CLIENT__ = queryClient;
|
|
return () => {
|
|
if (e2eWindow.__BUZZ_E2E_QUERY_CLIENT__ === queryClient) {
|
|
delete e2eWindow.__BUZZ_E2E_QUERY_CLIENT__;
|
|
}
|
|
};
|
|
}, [queryClient]);
|
|
|
|
return (
|
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
);
|
|
}
|
|
|
|
function AppReady({
|
|
canBackToWorkspaceSetup,
|
|
isCompletingFirstRunWorkspace,
|
|
isSharedIdentity,
|
|
isWorkspaceSwitch,
|
|
onFirstRunWorkspaceSettled,
|
|
onBackToWorkspaceSetup,
|
|
}: {
|
|
canBackToWorkspaceSetup: boolean;
|
|
isCompletingFirstRunWorkspace: boolean;
|
|
isSharedIdentity: boolean;
|
|
isWorkspaceSwitch: boolean;
|
|
onFirstRunWorkspaceSettled: () => void;
|
|
onBackToWorkspaceSetup: () => void;
|
|
}) {
|
|
const onboarding = useAppOnboardingState(isSharedIdentity);
|
|
|
|
useEffect(() => {
|
|
if (isCompletingFirstRunWorkspace && onboarding.stage !== "blocking") {
|
|
onFirstRunWorkspaceSettled();
|
|
}
|
|
}, [
|
|
isCompletingFirstRunWorkspace,
|
|
onboarding.stage,
|
|
onFirstRunWorkspaceSettled,
|
|
]);
|
|
|
|
if (onboarding.stage === "keyring-locked") {
|
|
return <KeyringLockedScreen />;
|
|
}
|
|
|
|
if (onboarding.stage === "relaunch-required") {
|
|
return <RelaunchRequiredScreen />;
|
|
}
|
|
|
|
if (onboarding.stage === "onboarding") {
|
|
return (
|
|
<OnboardingFlow
|
|
actions={onboarding.flow.actions}
|
|
canBackToWorkspaceSetup={canBackToWorkspaceSetup}
|
|
identityLost={onboarding.identityLost}
|
|
initialProfile={onboarding.flow.initialProfile}
|
|
key={onboarding.currentPubkey ?? "anonymous"}
|
|
onBackToWorkspaceSetup={onBackToWorkspaceSetup}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (onboarding.stage === "blocking") {
|
|
if (isCompletingFirstRunWorkspace) {
|
|
return <OnboardingLoadingGate />;
|
|
}
|
|
|
|
return isWorkspaceSwitch ? <WorkspaceSwitchGate /> : <AppLoadingGate />;
|
|
}
|
|
|
|
return <RouterProvider router={router} />;
|
|
}
|
|
|
|
export function App() {
|
|
// Mounted at the root so Cmd/Ctrl+R reloads in every app state,
|
|
// including the loading and first-run setup screens below.
|
|
useReloadShortcut();
|
|
useInitialRenderReady();
|
|
|
|
const [sharedIdentity, setSharedIdentity] = useState<boolean | null>(null);
|
|
useEffect(() => {
|
|
isSharedIdentityCmd()
|
|
.then(setSharedIdentity)
|
|
.catch((err) => {
|
|
console.warn("is_shared_identity command failed:", err);
|
|
setSharedIdentity(false);
|
|
});
|
|
}, []);
|
|
|
|
const {
|
|
activeWorkspace,
|
|
reinitKey,
|
|
addWorkspace,
|
|
clearWorkspaces,
|
|
switchWorkspace,
|
|
reconnectWorkspace,
|
|
} = useWorkspaces();
|
|
const [isCompletingFirstRunWorkspace, setIsCompletingFirstRunWorkspace] =
|
|
useState(false);
|
|
const [canBackToWorkspaceSetup, setCanBackToWorkspaceSetup] = useState(false);
|
|
const [welcomeTransitionMode, setWelcomeTransitionMode] = useState<
|
|
"initial" | "backward"
|
|
>("initial");
|
|
|
|
useEffect(() => {
|
|
const unlisten = listenForDeepLinks({
|
|
addWorkspace,
|
|
switchWorkspace,
|
|
reconnectWorkspace,
|
|
});
|
|
return () => {
|
|
void unlisten.then((fn) => fn());
|
|
};
|
|
}, [addWorkspace, switchWorkspace, reconnectWorkspace]);
|
|
// Surface nest-related backend events (repos-dir errors, legacy migration)
|
|
// as toasts. Mounted before useWorkspaceInit so the listeners are registered
|
|
// ahead of the first apply_workspace call.
|
|
useNestNotifications();
|
|
|
|
// Composite key: changes when workspace ID changes OR when
|
|
// the active workspace's config is updated (relayUrl/token).
|
|
const workspaceKey = `${activeWorkspace?.id ?? "none"}-${reinitKey}`;
|
|
|
|
// Latch once the workspace key deviates from its cold-boot value: from then
|
|
// on, loading phases are in-app switches and get the quiet gate instead of
|
|
// the full "Setting up your workspace" splash.
|
|
const initialWorkspaceKeyRef = useRef(workspaceKey);
|
|
const hasSwitchedWorkspaceRef = useRef(false);
|
|
if (workspaceKey !== initialWorkspaceKeyRef.current) {
|
|
hasSwitchedWorkspaceRef.current = true;
|
|
}
|
|
const isWorkspaceSwitch = hasSwitchedWorkspaceRef.current;
|
|
|
|
const workspace = useWorkspaceInit(
|
|
activeWorkspace,
|
|
workspaceKey,
|
|
sharedIdentity ?? false,
|
|
);
|
|
|
|
const handleSetupComplete = useCallback(
|
|
(workspace: Workspace) => {
|
|
setWelcomeTransitionMode("initial");
|
|
setIsCompletingFirstRunWorkspace(true);
|
|
setCanBackToWorkspaceSetup(true);
|
|
const workspaceId = addWorkspace(workspace);
|
|
switchWorkspace(workspaceId);
|
|
},
|
|
[addWorkspace, switchWorkspace],
|
|
);
|
|
|
|
const handleBackToWorkspaceSetup = useCallback(() => {
|
|
setWelcomeTransitionMode("backward");
|
|
setIsCompletingFirstRunWorkspace(false);
|
|
setCanBackToWorkspaceSetup(false);
|
|
clearWorkspaces();
|
|
}, [clearWorkspaces]);
|
|
|
|
const handleFirstRunWorkspaceSettled = useCallback(() => {
|
|
setIsCompletingFirstRunWorkspace(false);
|
|
}, []);
|
|
|
|
const bootSplashPhase = useBootSplashHold();
|
|
|
|
// Wait for the shared-identity IPC call to resolve before rendering
|
|
// anything that depends on it. Without this gate, children briefly see
|
|
// isSharedIdentity=false and may flash WelcomeSetup or the onboarding flow.
|
|
if (sharedIdentity === null) {
|
|
return <AppLoadingGate />;
|
|
}
|
|
|
|
// Show welcome setup for first-run users with no workspaces
|
|
if (workspace.needsSetup) {
|
|
return (
|
|
<WelcomeSetup
|
|
defaultRelayUrl={workspace.defaultRelayUrl}
|
|
initialTransitionMode={welcomeTransitionMode}
|
|
onComplete={handleSetupComplete}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Wait for this exact workspace 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 workspace while the Tauri
|
|
// backend is still configured for the previous one.
|
|
if (!workspace.isReady || workspace.appliedKey !== workspaceKey) {
|
|
if (isCompletingFirstRunWorkspace) {
|
|
return <OnboardingLoadingGate />;
|
|
}
|
|
|
|
return isWorkspaceSwitch ? <WorkspaceSwitchGate /> : <AppLoadingGate />;
|
|
}
|
|
|
|
// The app mounts (and starts loading data) beneath the splash overlay; the
|
|
// overlay just keeps the bee on screen long enough to be seen, then fades.
|
|
// Workspace switches and first-run completion keep their quiet gates.
|
|
const showBootSplashOverlay =
|
|
bootSplashPhase !== "done" &&
|
|
!isWorkspaceSwitch &&
|
|
!isCompletingFirstRunWorkspace;
|
|
|
|
return (
|
|
<WorkspaceQueryProvider key={workspaceKey}>
|
|
<AppReady
|
|
canBackToWorkspaceSetup={canBackToWorkspaceSetup}
|
|
isCompletingFirstRunWorkspace={isCompletingFirstRunWorkspace}
|
|
isWorkspaceSwitch={isWorkspaceSwitch}
|
|
key={workspaceKey}
|
|
isSharedIdentity={sharedIdentity}
|
|
onFirstRunWorkspaceSettled={handleFirstRunWorkspaceSettled}
|
|
onBackToWorkspaceSetup={handleBackToWorkspaceSetup}
|
|
/>
|
|
{showBootSplashOverlay ? (
|
|
<div
|
|
aria-hidden="true"
|
|
className={cn(
|
|
"fixed inset-0 z-50 transition-opacity",
|
|
bootSplashPhase === "fading" ? "opacity-0" : "opacity-100",
|
|
)}
|
|
data-testid="boot-splash-overlay"
|
|
style={{ transitionDuration: `${BOOT_SPLASH_FADE_MS}ms` }}
|
|
>
|
|
<AppLoadingGate />
|
|
</div>
|
|
) : null}
|
|
</WorkspaceQueryProvider>
|
|
);
|
|
}
|