mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
**Category:** improvement **User Impact:** People can leave their final Buzz community and return to **Join or create a community** without losing their signed-in identity. **Problem:** Buzz Desktop blocked people from leaving when only one community remained. Its existing remove action also changed local configuration without ending relay membership. **Solution:** Allow the final community to be left. Buzz now asks the relay to end membership, removes the community locally only after acceptance, and returns the person to the community selector while keeping their identity signed in. If other communities remain, Buzz switches to one of them. Relay rejection or timeout keeps the community in place and shows an actionable retry error. <details> <summary>File changes</summary> **desktop/src/features/communities/leaveCommunity.ts** Adds signed kind 28936 publishing for active and inactive community relays with actionable timeout handling. **desktop/src/features/communities/leaveCommunity.test.mjs** Covers event shape, relay selection, acceptance gating, rejection, timeout messaging, and cleanup. **desktop/src/features/communities/useCommunities.tsx** Allows final-community removal and clears community-specific storage without touching identity. **desktop/src/features/communities/resolveCommunityRemoval.test.mjs** Covers final, active, and inactive community removal state transitions. **desktop/src/app/useCommunityNavigationTransitions.ts** Gates local removal on relay acceptance and routes to a fallback community or setup selector. **desktop/src/app/AppShell.tsx** Passes the asynchronous leave operation through shell entry points. **desktop/src/features/communities/ui/EditCommunityDialog.tsx** Replaces the local-only remove action with a pending-aware Leave Community action that retains actionable errors. **desktop/src/features/communities/ui/CommunitySwitcher.tsx** Enables leaving the final community and carries the asynchronous callback. **desktop/src/features/sidebar/ui/AppSidebar.tsx** Carries the asynchronous leave callback through sidebar props. **desktop/src/features/sidebar/ui/CommunityRail.tsx** Enables leaving the final community from rail settings. **desktop/src/features/sidebar/ui/SidebarProfileCard.tsx** Carries the asynchronous leave callback through profile community settings. **desktop/src/testing/e2eBridge.ts** Teaches the mock relay to accept NIP-43 leave events. **desktop/tests/e2e/community-rail.spec.ts** Updates leave interactions and verifies final-community setup navigation, storage cleanup, and identity preservation. </details> ### Reproduction steps 1. Run Buzz Desktop with a signed-in identity and one joined community. 2. Open Community settings and choose **Leave Community**. 3. Confirm the app shows **Join or create a community** and the existing identity remains signed in. 4. Repeat with two communities and confirm leaving the active one switches cleanly to the remaining community. 5. Reject or withhold the relay `OK` response and confirm the community remains configured with an actionable error in the dialog. ### Test plan - `pnpm check` - `pnpm build` - `pnpm test` (3,913 passing) - `pnpm build:e2e && pnpm exec playwright test tests/e2e/community-rail.spec.ts --grep "final community"` <img width="557" height="316" alt="image" src="https://github.com/user-attachments/assets/b628182f-cba5-451d-ae4b-bee8d8dd19aa" /> --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: npub14ndfusear8wdpe4kss8h7juc7wjk78atnqzf63zvppcpneknv4sq6x9370 <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz> Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Co-authored-by: npub14ndfusear8wdpe4kss8h7juc7wjk78atnqzf63zvppcpneknv4sq6x9370 <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
742 lines
25 KiB
TypeScript
742 lines
25 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 {
|
|
completeCommunityViewTransition,
|
|
replaceCommunityDestinationRoute,
|
|
} from "@/app/communityViewTransition";
|
|
import { deriveShellRoute } from "@/app/AppShell.helpers";
|
|
import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground";
|
|
import { CommunityThemeController } from "@/shared/theme/CommunityThemeController";
|
|
import { useReloadShortcut } from "@/app/useReloadShortcut";
|
|
import { KnownAgentPubkeysProvider } from "@/features/agents/useKnownAgentPubkeys";
|
|
import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow";
|
|
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,
|
|
type PostOnboardingNavigation,
|
|
} 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 { loadCommunityDiscoveryAfterLeave } from "@/features/communities/communityStorage";
|
|
import { useCommunityInit } from "@/features/communities/useCommunityInit";
|
|
import { useNestNotifications } from "@/features/communities/useNestNotifications";
|
|
import { useCommunities } from "@/features/communities/useCommunities";
|
|
import {
|
|
loadCommunityDestination,
|
|
markPendingCommunityRestore,
|
|
saveCommunityDestination,
|
|
} from "@/features/communities/communityNavigationStorage";
|
|
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 { EncryptedBackupProvider } from "@/features/settings/EncryptedBackupProvider";
|
|
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 (
|
|
<EncryptedBackupProvider
|
|
onOpenSettings={() =>
|
|
void router.navigate({
|
|
to: "/settings",
|
|
search: { section: "profile" },
|
|
})
|
|
}
|
|
>
|
|
<KnownAgentPubkeysProvider>
|
|
<RouterProvider router={router} />
|
|
</KnownAgentPubkeysProvider>
|
|
</EncryptedBackupProvider>
|
|
);
|
|
}
|
|
|
|
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);
|
|
const isFindingCommunityAfterLeave =
|
|
activeCommunity === null && loadCommunityDiscoveryAfterLeave();
|
|
|
|
// 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,
|
|
isFindingCommunityAfterLeave,
|
|
);
|
|
|
|
const transitionCommunity = useCallback(
|
|
async (targetCommunityId: string) => {
|
|
const activeCommunityId = activeCommunity?.id;
|
|
if (targetCommunityId === activeCommunityId) return;
|
|
if (activeCommunityId) {
|
|
const route = deriveShellRoute(router.state.location.pathname);
|
|
saveCommunityDestination(
|
|
activeCommunityId,
|
|
route.selectedView === "channel" && route.selectedChannelId
|
|
? { kind: "channel", channelId: route.selectedChannelId }
|
|
: { kind: "home" },
|
|
);
|
|
await router.navigate({ to: "/", replace: true });
|
|
markPendingCommunityRestore(targetCommunityId);
|
|
const destination = loadCommunityDestination(targetCommunityId);
|
|
if (destination?.kind === "channel") {
|
|
replaceCommunityDestinationRoute(
|
|
destination.channelId,
|
|
router.history,
|
|
);
|
|
}
|
|
}
|
|
switchCommunity(targetCommunityId);
|
|
},
|
|
[activeCommunity?.id, switchCommunity],
|
|
);
|
|
|
|
const handleCommunityOnboardingConnect = useCallback(async () => {
|
|
const transaction = communityOnboarding.transaction;
|
|
if (transaction?.stage !== "connecting") return;
|
|
if (connectingTransactionRef.current === transaction.id) return;
|
|
connectingTransactionRef.current = transaction.id;
|
|
if (transaction.communityId) {
|
|
await transitionCommunity(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,
|
|
});
|
|
await transitionCommunity(id);
|
|
reconnectCommunity();
|
|
}, [
|
|
activeCommunity?.id,
|
|
addCommunity,
|
|
communities,
|
|
communityOnboarding,
|
|
currentPubkey,
|
|
reconnectCommunity,
|
|
transitionCommunity,
|
|
]);
|
|
|
|
const handleCommunityOnboardingCancel = useCallback(async () => {
|
|
const transaction = communityOnboarding.transaction;
|
|
communityOnboarding.clear();
|
|
|
|
if (!transaction?.communityId) return;
|
|
if (!transaction.addedCommunity) {
|
|
if (transaction.previousCommunityId) {
|
|
await transitionCommunity(transaction.previousCommunityId);
|
|
}
|
|
return;
|
|
}
|
|
if (communities.length === 1) {
|
|
if (transaction.source === "first-community") {
|
|
setResumeFirstCommunityPage(transaction.firstCommunityPage ?? "join");
|
|
}
|
|
clearCommunities();
|
|
return;
|
|
}
|
|
if (transaction.previousCommunityId) {
|
|
await transitionCommunity(transaction.previousCommunityId);
|
|
}
|
|
removeCommunity(transaction.communityId);
|
|
}, [
|
|
clearCommunities,
|
|
communities.length,
|
|
communityOnboarding,
|
|
removeCommunity,
|
|
transitionCommunity,
|
|
]);
|
|
|
|
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={
|
|
isFindingCommunityAfterLeave ? undefined : 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;
|
|
useLayoutEffect(() => {
|
|
if (communityApplied) {
|
|
completeCommunityViewTransition();
|
|
}
|
|
}, [communityApplied]);
|
|
if (appContent === null && (!transaction || isEnteringCurtain)) {
|
|
appContent = communityApplied ? (
|
|
<CommunityQueryProvider key={communityKey}>
|
|
<CommunityThemeController />
|
|
<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 [postOnboardingNav, setPostOnboardingNav] =
|
|
useState<PostOnboardingNavigation | null>(null);
|
|
|
|
const reopenMachineConfig = useCallback(() => {
|
|
setMachineInitialPage("config");
|
|
machine.reopen();
|
|
}, [machine.reopen]);
|
|
|
|
const completeMachineOnboarding = useCallback(
|
|
(pubkey?: string) => {
|
|
setMachineInitialPage(undefined);
|
|
machine.complete(pubkey);
|
|
},
|
|
[machine.complete],
|
|
);
|
|
|
|
const navigateAfterOnboarding = useCallback(
|
|
(nav: PostOnboardingNavigation) => {
|
|
setPostOnboardingNav(nav);
|
|
},
|
|
[],
|
|
);
|
|
|
|
// Execute the pending navigation once the RouterProvider is mounted (i.e.
|
|
// machine.stage transitions to "ready"). We wait for the ready stage rather
|
|
// than using setTimeout(0) so the router is guaranteed to exist before we call
|
|
// router.navigate().
|
|
useEffect(() => {
|
|
if (machine.stage === "ready" && postOnboardingNav) {
|
|
void router.navigate({
|
|
to: postOnboardingNav.to,
|
|
search: postOnboardingNav.search ?? {},
|
|
});
|
|
setPostOnboardingNav(null);
|
|
}
|
|
}, [machine.stage, postOnboardingNav]);
|
|
|
|
const openAddCommunity = useCallback(
|
|
(payload: AddCommunityDeepLinkPayload & { requestId: string }) =>
|
|
activeCommunity
|
|
? requestAddCommunityPrefill(payload)
|
|
: communityOnboarding.start({
|
|
source: "add-community",
|
|
relayUrl: payload.relayUrl,
|
|
communityName: payload.name,
|
|
}),
|
|
[activeCommunity, communityOnboarding.start],
|
|
);
|
|
|
|
// Community links are app-global work. A Huddle companion loads the same
|
|
// React tree, but must never race the main window for the native pending-link
|
|
// queue or replace its dedicated transcript surface with onboarding.
|
|
const acceptsCommunityDeepLinks = huddleWindowChannelId() === null;
|
|
useEffect(() => {
|
|
if (!acceptsCommunityDeepLinks) return;
|
|
|
|
const unlisten = listenForDeepLinks({
|
|
startCommunityOnboarding: communityOnboarding.start,
|
|
openAddCommunity,
|
|
onAddCommunityAvailable: onAddCommunityPrefillAvailable,
|
|
});
|
|
return () => {
|
|
void unlisten.then((fn) => fn());
|
|
};
|
|
}, [acceptsCommunityDeepLinks, 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}
|
|
navigateAfterComplete={navigateAfterOnboarding}
|
|
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>
|
|
);
|
|
}
|