Files
buzz/desktop/src/app/App.tsx
2026-07-23 12:45:00 -07:00

655 lines
22 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 {
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 { KnownAgentPubkeysProvider } from "@/features/agents/useKnownAgentPubkeys";
import { useAppOnboardingState } from "@/features/onboarding/hooks";
import { useMachineOnboardingState } from "@/features/onboarding/machineOnboarding";
import {
type FirstCommunityPage,
useCommunityOnboarding,
markCommunityOnboardingComplete,
resolveProfileCheckAction,
isTransactionStillConnecting,
} from "@/features/onboarding/communityOnboarding";
import { CommunityOnboardingFlow } from "@/features/onboarding/ui/CommunityOnboardingFlow";
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";
import { RelaunchRequiredScreen } from "@/features/onboarding/ui/RelaunchRequiredScreen";
import { ResetFailedScreen } from "@/features/onboarding/ui/ResetFailedScreen";
import { useCommunityInit } from "@/features/communities/useCommunityInit";
import { useNestNotifications } from "@/features/communities/useNestNotifications";
import { useCommunities } from "@/features/communities/useCommunities";
import {
onAddCommunityPrefillAvailable,
requestAddCommunityPrefill,
} from "@/features/communities/addCommunityPrefill";
import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup";
import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen";
import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay";
import { setAvatarProfileSyncQueryClient } from "@/features/profile/avatarProfileSync";
import { createBuzzQueryClient } from "@/shared/api/queryClient";
import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri";
import { getProfile } from "@/shared/api/tauriProfiles";
import {
type AddCommunityDeepLinkPayload,
listenForDeepLinks,
} from "@/shared/deep-link";
import { cn } from "@/shared/lib/cn";
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";
const LOADING_TEXT = "Setting up your community...";
// Minimum time the cold-boot splash stays on screen. A real boot resolves the
// community 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 community" 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 communities: visually empty
// unless the switch takes long, so fast switches don't flash the boot splash.
function CommunitySwitchGate() {
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="community-switch-gate"
role="status"
>
<StartupWindowDragRegion />
<span className="sr-only">Switching community…</span>
{showSpinner ? (
<BeeLoader
ariaLabel="Switching community…"
className="h-auto w-20"
tintClassName="text-muted-foreground"
/>
) : null}
</div>
);
}
function CommunityQueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(createBuzzQueryClient);
useEffect(() => setAvatarProfileSyncQueryClient(queryClient), [queryClient]);
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({
isSharedIdentity,
isCommunitySwitch,
}: {
isSharedIdentity: boolean;
isCommunitySwitch: boolean;
}) {
const onboarding = useAppOnboardingState(isSharedIdentity);
if (onboarding.stage === "reset-failed") {
return <ResetFailedScreen />;
}
if (onboarding.stage === "keyring-locked") {
return <KeyringLockedScreen />;
}
if (onboarding.stage === "relaunch-required") {
return <RelaunchRequiredScreen />;
}
if (onboarding.stage === "onboarding") {
return (
<OnboardingFlow
actions={onboarding.flow.actions}
identityLost={onboarding.identityLost}
initialProfile={onboarding.flow.initialProfile}
key={onboarding.currentPubkey ?? "anonymous"}
/>
);
}
if (onboarding.stage === "blocking") {
return isCommunitySwitch ? <CommunitySwitchGate /> : <AppLoadingGate />;
}
return (
<KnownAgentPubkeysProvider>
<RouterProvider router={router} />
</KnownAgentPubkeysProvider>
);
}
function CommunityApp({
currentPubkey,
onBackToMachineConfig,
sharedIdentity,
}: {
currentPubkey: string | null;
onBackToMachineConfig: () => void;
sharedIdentity: boolean;
}) {
const {
activeCommunity,
communities,
reinitKey,
addCommunity,
clearCommunities,
removeCommunity,
switchCommunity,
reconnectCommunity,
} = useCommunities();
const communityOnboarding = useCommunityOnboarding();
const connectingTransactionRef = useRef<string | null>(null);
// Tracks the ID of the profile-check request that has been launched for the
// current connecting transaction. Prevents the effect from launching a
// second request if it re-runs while a fetch is in flight.
const profileCheckTransactionRef = useRef<string | null>(null);
// Always reflects the live transaction object so async callbacks can perform
// an atomic check of both ID and stage before mutating state.
const transactionRef = useRef(communityOnboarding.transaction);
transactionRef.current = communityOnboarding.transaction;
const [isCommunityChangeOpen, setIsCommunityChangeOpen] = useState(false);
const [resumeFirstCommunityPage, setResumeFirstCommunityPage] =
useState<FirstCommunityPage | null>(null);
// Surface nest-related backend events (repos-dir errors, legacy migration)
// as toasts. Mounted before useCommunityInit so the listeners are registered
// ahead of the first apply_workspace call.
useNestNotifications();
// Composite key: changes when community ID changes OR when
// the active community's config is updated (relayUrl/token).
const communityKey = `${activeCommunity?.id ?? "none"}-${reinitKey}`;
// Latch once the community 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 community" splash.
const initialCommunityKeyRef = useRef(communityKey);
const hasSwitchedCommunityRef = useRef(false);
if (communityKey !== initialCommunityKeyRef.current) {
hasSwitchedCommunityRef.current = true;
}
const isCommunitySwitch = hasSwitchedCommunityRef.current;
const community = useCommunityInit(
activeCommunity,
communityKey,
sharedIdentity,
);
const handleCommunityOnboardingConnect = useCallback(() => {
const transaction = communityOnboarding.transaction;
if (transaction?.stage !== "connecting") return;
if (connectingTransactionRef.current === transaction.id) return;
connectingTransactionRef.current = transaction.id;
if (transaction.communityId) {
switchCommunity(transaction.communityId);
return;
}
const previousCommunityId = activeCommunity?.id;
const relayAlreadyExists = communities.some(
(community) => community.relayUrl === transaction.relayUrl,
);
const id = addCommunity({
id: crypto.randomUUID(),
name: transaction.communityName,
relayUrl: transaction.relayUrl,
token: transaction.token,
reposDir: transaction.reposDir,
pubkey: currentPubkey ?? undefined,
addedAt: new Date().toISOString(),
});
communityOnboarding.update({
communityId: id,
previousCommunityId,
addedCommunity: !relayAlreadyExists,
error: undefined,
});
switchCommunity(id);
reconnectCommunity();
}, [
activeCommunity?.id,
addCommunity,
communities,
communityOnboarding,
currentPubkey,
reconnectCommunity,
switchCommunity,
]);
const handleCommunityOnboardingCancel = useCallback(() => {
const transaction = communityOnboarding.transaction;
communityOnboarding.clear();
if (!transaction?.communityId) return;
if (!transaction.addedCommunity) {
if (transaction.previousCommunityId) {
switchCommunity(transaction.previousCommunityId);
}
return;
}
if (communities.length === 1) {
if (transaction.source === "first-community") {
setResumeFirstCommunityPage(transaction.firstCommunityPage ?? "join");
}
clearCommunities();
return;
}
removeCommunity(transaction.communityId);
if (transaction.previousCommunityId) {
switchCommunity(transaction.previousCommunityId);
}
}, [
clearCommunities,
communities.length,
communityOnboarding,
removeCommunity,
switchCommunity,
]);
const bootSplashPhase = useBootSplashHold();
const transaction = communityOnboarding.transaction;
useEffect(() => {
if (transaction?.stage !== "connecting") {
connectingTransactionRef.current = null;
profileCheckTransactionRef.current = null;
}
}, [transaction?.stage]);
const targetIsReady =
transaction?.communityId === activeCommunity?.id &&
community.isReady &&
community.appliedKey === communityKey;
useEffect(() => {
if (transaction?.stage !== "connecting" || !targetIsReady) return;
const transactionId = transaction.id;
const relayUrl = transaction.relayUrl;
if (profileCheckTransactionRef.current === transactionId) return;
profileCheckTransactionRef.current = transactionId;
// resolveProfileCheckAction resolves exactly once (Promise.race + timer
// cleared on settle), so no settled flag is needed here.
void resolveProfileCheckAction(getProfile, 10_000).then((result) => {
// Atomic staleness guard via isTransactionStillConnecting: the
// transaction must still be the same one that launched this request
// AND still be in connecting. Covers cancel+replacement (B's ID !== A's)
// and cancel-without-replacement (transactionRef.current is null).
if (!isTransactionStillConnecting(transactionRef.current, transactionId))
return;
if (result.action === "skip") {
markCommunityOnboardingComplete(result.profile.pubkey, relayUrl);
communityOnboarding.clear();
} else {
communityOnboarding.update(
{ stage: "profile", error: undefined },
transactionId,
);
}
});
}, [
communityOnboarding,
targetIsReady,
transaction?.stage,
transaction?.id,
transaction?.relayUrl,
]);
// During "entering" the transaction stays alive as a curtain: the app mounts
// underneath (already pointed at the Welcome channel route) while the
// onboarding screen covers it, then fades once Welcome reports ready.
//
// The flow must keep ONE stable position in the element tree across every
// stage. Rendering it from a different slot when the stage flips to
// "entering" would remount it — React state resets and the "Meet your
// starter team" screen visibly restarts mid-handoff.
const isEnteringCurtain = transaction?.stage === "entering";
// 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.
// Community switches keep their quiet gate.
const showBootSplashOverlay =
bootSplashPhase !== "done" && !isCommunitySwitch;
let appContent: ReactNode = null;
if (!transaction) {
if (community.needsSetup) {
// Show welcome setup for first-run users with no communities
appContent = (
<WelcomeSetup
initialPage={resumeFirstCommunityPage ?? undefined}
onBack={onBackToMachineConfig}
/>
);
} else if ("error" in community && community.error) {
// Surface apply failures so the user can retry or change community.
appContent = (
<>
<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 backend is still configured for the previous one.
const communityApplied =
community.isReady && community.appliedKey === communityKey;
if (appContent === null && (!transaction || isEnteringCurtain)) {
appContent = communityApplied ? (
<CommunityQueryProvider key={communityKey}>
<AppReady
isCommunitySwitch={isCommunitySwitch}
key={communityKey}
isSharedIdentity={sharedIdentity}
/>
{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}
</CommunityQueryProvider>
) : isCommunitySwitch ? (
<CommunitySwitchGate />
) : (
<AppLoadingGate />
);
}
return (
<>
{appContent}
{transaction ? (
<div
className={isEnteringCurtain ? "fixed inset-0 z-50" : undefined}
data-testid={
isEnteringCurtain ? "onboarding-entering-curtain" : undefined
}
>
<CommunityOnboardingFlow
onCancel={handleCommunityOnboardingCancel}
onConnect={handleCommunityOnboardingConnect}
/>
</div>
) : null}
</>
);
}
function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) {
const { activeCommunity } = useCommunities();
const communityOnboarding = useCommunityOnboarding();
const machine = useMachineOnboardingState({
activeCommunityPubkey: activeCommunity
? (activeCommunity.pubkey ?? null)
: undefined,
isSharedIdentity: sharedIdentity,
});
const [machineInitialPage, setMachineInitialPage] =
useState<MachineOnboardingPage>();
const reopenMachineConfig = useCallback(() => {
setMachineInitialPage("config");
machine.reopen();
}, [machine.reopen]);
const completeMachineOnboarding = useCallback(
(pubkey?: string) => {
setMachineInitialPage(undefined);
machine.complete(pubkey);
},
[machine.complete],
);
const openAddCommunity = useCallback(
(payload: AddCommunityDeepLinkPayload & { requestId: string }) =>
activeCommunity
? requestAddCommunityPrefill(payload)
: communityOnboarding.start({
source: "add-community",
relayUrl: payload.relayUrl,
communityName: payload.name,
}),
[activeCommunity, communityOnboarding.start],
);
// Deep links are captured here — above the machine-onboarding gate — not in
// CommunityApp. The Rust side queues them; draining into the persisted
// community-onboarding transaction immediately means an invite opened on a
// fresh install is acknowledged on screen while the identity steps are
// still pending, and survives a relaunch in between.
useEffect(() => {
const unlisten = listenForDeepLinks({
startCommunityOnboarding: communityOnboarding.start,
openAddCommunity,
onAddCommunityAvailable: onAddCommunityPrefillAvailable,
});
return () => {
void unlisten.then((fn) => fn());
};
}, [communityOnboarding.start, openAddCommunity]);
if (machine.stage === "reset-failed") return <ResetFailedScreen />;
if (machine.stage === "keyring-locked") return <KeyringLockedScreen />;
if (machine.stage === "relaunch-required") return <RelaunchRequiredScreen />;
if (machine.stage === "blocking") return <AppLoadingGate />;
if (machine.stage === "ready") {
return (
<CommunityApp
currentPubkey={machine.currentPubkey}
onBackToMachineConfig={reopenMachineConfig}
sharedIdentity={sharedIdentity}
/>
);
}
// A community deep link that arrived before machine onboarding finished is
// persisted immediately and acknowledged here. Invite claiming waits until
// setup completes so it is signed only by the user's final identity.
const transaction = communityOnboarding.transaction;
const isDeepLink =
transaction?.source === "deep-link-join" ||
transaction?.source === "deep-link-connect";
const shouldAcknowledgeDeepLink = isDeepLink && !transaction.acknowledged;
return (
<>
<MachineOnboardingFlow
complete={completeMachineOnboarding}
continueWithIdentity={machine.continueWithIdentity}
identityLost={machine.identityLost}
initialPage={machineInitialPage}
queryClient={machine.queryClient}
/>
{shouldAcknowledgeDeepLink ? <PendingInviteGate /> : null}
</>
);
}
export function App() {
useReloadShortcut();
useInitialRenderReady();
const [sharedIdentity, setSharedIdentity] = useState<boolean | null>(null);
const [queryClient] = useState(createBuzzQueryClient);
useEffect(() => {
isSharedIdentityCmd()
.then(setSharedIdentity)
.catch((err) => {
console.warn("is_shared_identity command failed:", err);
setSharedIdentity(false);
});
}, []);
if (sharedIdentity === null) return <AppLoadingGate />;
return (
<QueryClientProvider client={queryClient}>
<MachineBootstrap sharedIdentity={sharedIdentity} />
</QueryClientProvider>
);
}