mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Signed-off-by: Logan Johnson <loganj@squareup.com> Signed-off-by: npub1dpf98sl35hm9k65t8h6cvh5n6knn2msugh5k5nmysxwnw5wlh7uqn2wgp4 <685253c3f1a5f65b6a8b3df5865e93d5a7356e1c45e96a4f64819d3751dfbfb8@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: npub1dpf98sl35hm9k65t8h6cvh5n6knn2msugh5k5nmysxwnw5wlh7uqn2wgp4 <685253c3f1a5f65b6a8b3df5865e93d5a7356e1c45e96a4f64819d3751dfbfb8@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
655 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|