Files
buzz/desktop/src/features/onboarding/hooks.ts

683 lines
21 KiB
TypeScript

import * as React from "react";
import { useQueryClient, type QueryStatus } from "@tanstack/react-query";
import { toast } from "sonner";
import {
managedAgentsQueryKey,
relayAgentsQueryKey,
} from "@/features/agents/hooks";
import { channelsQueryKey } from "@/features/channels/hooks";
import {
ensureStarterChannels,
ensureWelcomeChannel,
hasEnsuredWelcomeChannel,
markWelcomeChannelEnsured,
notifyWelcomeChannelReady,
rememberPendingWelcomeChannel,
} from "@/features/onboarding/welcome";
import { forceFreshOnboarding } from "@/features/onboarding/devFreshOnboarding";
import { ensureWelcomeCanvas } from "@/features/onboarding/welcomeCanvas";
import { ensureWelcomeTeam } from "@/features/onboarding/welcomeGuide";
import { useProfileQuery } from "@/features/profile/hooks";
import { useCommunities } from "@/features/communities/useCommunities";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { Channel } from "@/shared/api/types";
import {
createChannel,
deleteChannel,
ensureStarterChannels as ensureStarterChannelsCommand,
getChannelMembers,
getChannels,
updateChannel,
} from "@/shared/api/tauri";
const STARTER_CHANNEL_SETUP_TOAST_ID = "starter-channel-setup-error";
export type ChannelInitResult =
| { ok: true; focusChannelId?: string }
| { ok: false; reason: string; focusChannelId?: string };
const welcomeSeedPromises = new Map<string, Promise<void>>();
function seedWelcomeExperience(
queryClient: ReturnType<typeof useQueryClient>,
channelId: string,
pubkey: string | null,
communityScope: string | null,
) {
const key = `${communityScope ?? ""}:${channelId}`;
const current = welcomeSeedPromises.get(key);
if (current) return current;
const promise = (async () => {
try {
await ensureWelcomeTeam(channelId, communityScope);
await ensureWelcomeCanvas(channelId);
await Promise.all([
queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }),
]);
markWelcomeChannelEnsured(pubkey, communityScope);
} catch (error) {
console.warn("Failed to seed the private Welcome experience.", error);
}
})().finally(() => welcomeSeedPromises.delete(key));
welcomeSeedPromises.set(key, promise);
return promise;
}
export async function initializeStarterChannels(
queryClient: ReturnType<typeof useQueryClient>,
{
focus,
pubkey,
communityScope,
}: {
focus: boolean;
pubkey: string | null;
communityScope: string | null;
},
): Promise<ChannelInitResult> {
try {
let starterChannels: Awaited<
ReturnType<typeof ensureStarterChannels>
> | null = null;
let starterChannelsError: unknown = null;
try {
starterChannels = await ensureStarterChannels({
ensureStarterChannels: ensureStarterChannelsCommand,
getChannels,
});
} catch (error) {
starterChannelsError = error;
console.warn("Failed to initialize public starter channels.", error);
}
const welcomeChannel = await ensureWelcomeChannel(
{
createChannel,
deleteChannel,
getChannelMembers,
getChannels,
updateChannel,
},
{
replaceExisting: forceFreshOnboarding,
},
);
const starterChannelList = starterChannels?.channels ?? [];
queryClient.setQueryData<Channel[]>(channelsQueryKey, (channels = []) => {
const ensuredIds = new Set(
starterChannelList.map((channel) => channel.id),
);
ensuredIds.add(welcomeChannel.id);
return [
...starterChannelList,
...(starterChannelList.some(
(channel) => channel.id === welcomeChannel.id,
)
? []
: [welcomeChannel]),
...channels.filter((channel) => !ensuredIds.has(channel.id)),
];
});
void seedWelcomeExperience(
queryClient,
welcomeChannel.id,
pubkey,
communityScope,
);
await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
if (focus) {
// Refreshing can briefly replace the optimistic cache with an older relay
// snapshot. Reinsert the just-ensured channels before announcing focus so
// the route can consume the pending private Welcome channel immediately.
queryClient.setQueryData<Channel[]>(channelsQueryKey, (channels = []) => {
const byId = new Map(
[...channels, ...starterChannelList, welcomeChannel].map(
(channel) => [channel.id, channel],
),
);
return [...byId.values()];
});
rememberPendingWelcomeChannel(welcomeChannel.id);
notifyWelcomeChannelReady(welcomeChannel.id);
}
const focusChannelId = focus ? welcomeChannel.id : undefined;
if (starterChannelsError) {
return {
ok: false,
focusChannelId,
reason:
starterChannelsError instanceof Error
? starterChannelsError.message
: "Failed to set up starter channels",
};
}
return { ok: true, focusChannelId };
} catch (error) {
console.warn("Failed to initialize starter channels.", error);
return {
ok: false,
reason:
error instanceof Error
? error.message
: "Failed to set up starter channels",
};
}
}
async function refreshChannelsCache(
queryClient: ReturnType<typeof useQueryClient>,
) {
try {
queryClient.setQueryData(channelsQueryKey, await getChannels());
} catch {
// The next mounted channels query can still retry; this cache refresh is
// only here to avoid a blank Home flash after first-run setup.
}
await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
}
const ONBOARDING_COMPLETION_STORAGE_KEY = "buzz-onboarding-complete.v1";
type OnboardingGateStage = "blocking" | "onboarding" | "ready";
type UseFirstRunOnboardingGateOptions = {
currentPubkey: string | null;
identityIsFetching: boolean;
identityLost: boolean;
identityStatus: QueryStatus;
isSharedIdentity: boolean;
profileHasEvent: boolean | undefined;
profileIsFetching: boolean;
profileStatus: QueryStatus;
};
type OnboardingGateState = {
currentPubkey: string | null;
hasCompletedCurrentPubkey: boolean;
hasSettledCurrentPubkey: boolean;
isOpen: boolean;
};
function onboardingCompletionStorageKey(pubkey: string) {
return `${ONBOARDING_COMPLETION_STORAGE_KEY}:${pubkey}`;
}
function readOnboardingCompletion(pubkey: string | null) {
if (forceFreshOnboarding) {
return false;
}
if (typeof window === "undefined" || !pubkey) {
return false;
}
return (
window.localStorage.getItem(onboardingCompletionStorageKey(pubkey)) ===
"true"
);
}
function createOnboardingGateState(pubkey: string | null): OnboardingGateState {
const hasCompletedCurrentPubkey = readOnboardingCompletion(pubkey);
return {
currentPubkey: pubkey,
hasCompletedCurrentPubkey,
hasSettledCurrentPubkey: hasCompletedCurrentPubkey,
isOpen: false,
};
}
function resolveActiveGateState(
gateState: OnboardingGateState,
currentPubkey: string | null,
) {
return gateState.currentPubkey === currentPubkey
? gateState
: createOnboardingGateState(currentPubkey);
}
function updateActiveGateState(
gateState: OnboardingGateState,
currentPubkey: string | null,
update: (activeGateState: OnboardingGateState) => OnboardingGateState,
) {
return update(resolveActiveGateState(gateState, currentPubkey));
}
function isSettledQueryStatus(status: QueryStatus) {
return status === "success" || status === "error";
}
function resolveOnboardingGateStage({
currentPubkey,
gateState,
identityIsFetching,
identityStatus,
}: {
currentPubkey: string | null;
gateState: OnboardingGateState;
identityIsFetching: boolean;
identityStatus: QueryStatus;
}): OnboardingGateStage {
const isBlockingCurrentPubkey =
currentPubkey !== null &&
!gateState.hasCompletedCurrentPubkey &&
(gateState.isOpen || !gateState.hasSettledCurrentPubkey);
if (gateState.isOpen) {
return "onboarding";
}
if (
identityIsFetching ||
!isSettledQueryStatus(identityStatus) ||
isBlockingCurrentPubkey
) {
return "blocking";
}
return "ready";
}
export function useFirstRunOnboardingGate({
currentPubkey,
identityIsFetching,
identityLost,
identityStatus,
isSharedIdentity,
profileHasEvent,
profileIsFetching,
profileStatus,
}: UseFirstRunOnboardingGateOptions) {
const [gateState, setGateState] = React.useState<OnboardingGateState>(() =>
createOnboardingGateState(currentPubkey),
);
const activeGateState = resolveActiveGateState(gateState, currentPubkey);
const { hasCompletedCurrentPubkey, hasSettledCurrentPubkey } =
activeGateState;
React.useEffect(() => {
setGateState((current) =>
current.currentPubkey === currentPubkey
? current
: createOnboardingGateState(currentPubkey),
);
}, [currentPubkey]);
// When the backend signals "identity lost" (keyring was cleared after a
// successful migration), force onboarding open immediately so the user can
// re-import their nsec. This runs once, after identity settles.
React.useEffect(() => {
if (!identityLost || !currentPubkey || identityStatus !== "success") {
return;
}
setGateState((current) =>
updateActiveGateState(current, currentPubkey, (activeGateState) => ({
...activeGateState,
hasCompletedCurrentPubkey: false,
hasSettledCurrentPubkey: true,
isOpen: true,
})),
);
}, [currentPubkey, identityLost, identityStatus]);
React.useEffect(() => {
// Fast-path: shared identity worktrees have already onboarded in the
// main checkout. Skip unconditionally without waiting for the relay
// profile query. Guarded by !hasCompletedCurrentPubkey so it fires once.
if (
!forceFreshOnboarding &&
isSharedIdentity &&
currentPubkey &&
identityStatus === "success" &&
!hasCompletedCurrentPubkey
) {
if (typeof window !== "undefined") {
window.localStorage.setItem(
onboardingCompletionStorageKey(currentPubkey),
"true",
);
}
setGateState((current) =>
updateActiveGateState(current, currentPubkey, (activeGateState) => ({
...activeGateState,
hasCompletedCurrentPubkey: true,
hasSettledCurrentPubkey: true,
isOpen: false,
})),
);
return;
}
// Original guard — restored to simple form.
if (hasSettledCurrentPubkey || !currentPubkey) {
return;
}
if (identityStatus === "error") {
setGateState((current) =>
updateActiveGateState(current, currentPubkey, (activeGateState) => ({
...activeGateState,
hasSettledCurrentPubkey: true,
})),
);
return;
}
if (identityStatus !== "success") {
return;
}
if (!isSettledQueryStatus(profileStatus) || profileIsFetching) {
return;
}
// If the relay has a real kind:0 metadata event for this pubkey, the user
// has previously completed onboarding (possibly on another machine or app
// data directory). Skip the onboarding flow and mark as complete so they
// go straight to the app.
//
// We gate on `hasProfileEvent` — a flag set by the Tauri backend when a
// real kind:0 event was found — rather than any field value. This correctly
// handles the case where a returning user's display_name is empty: the event
// still exists, so onboarding is skipped. A missing event (new user, or no
// kind:0 on the relay) always shows onboarding regardless of display_name.
const hasExistingProfile =
!forceFreshOnboarding &&
profileStatus === "success" &&
profileHasEvent === true;
setGateState((current) =>
updateActiveGateState(current, currentPubkey, (activeGateState) => {
// Re-read localStorage here to handle the webkit2gtk WAL race: the
// synchronous useState initializer may have run before the WAL was
// merged into the main SQLite file, returning null for a flag that is
// actually present. By the time this effect fires (identity + profile
// settled), the WAL has had time to merge and the read is reliable.
const hasCompletedAfterRecheck =
readOnboardingCompletion(currentPubkey);
const alreadyOnboarded =
activeGateState.hasCompletedCurrentPubkey ||
hasCompletedAfterRecheck ||
hasExistingProfile;
if (alreadyOnboarded && typeof window !== "undefined") {
window.localStorage.setItem(
onboardingCompletionStorageKey(currentPubkey),
"true",
);
}
return {
...activeGateState,
hasCompletedCurrentPubkey: alreadyOnboarded,
hasSettledCurrentPubkey: true,
isOpen: !alreadyOnboarded,
};
}),
);
}, [
currentPubkey,
hasCompletedCurrentPubkey,
hasSettledCurrentPubkey,
identityStatus,
isSharedIdentity,
profileHasEvent,
profileIsFetching,
profileStatus,
]);
const skipForNow = React.useCallback(() => {
setGateState((current) =>
updateActiveGateState(current, currentPubkey, (activeGateState) => ({
...activeGateState,
hasSettledCurrentPubkey: true,
isOpen: false,
})),
);
}, [currentPubkey]);
const complete = React.useCallback(() => {
if (typeof window !== "undefined" && currentPubkey) {
window.localStorage.setItem(
onboardingCompletionStorageKey(currentPubkey),
"true",
);
}
setGateState({
currentPubkey,
hasCompletedCurrentPubkey: true,
hasSettledCurrentPubkey: true,
isOpen: false,
});
}, [currentPubkey]);
return {
complete,
skipForNow,
stage: resolveOnboardingGateStage({
currentPubkey,
gateState: activeGateState,
identityIsFetching,
identityStatus,
}),
};
}
export function useAppOnboardingState(isSharedIdentity: boolean) {
const queryClient = useQueryClient();
const { activeCommunity } = useCommunities();
const identityQuery = useIdentityQuery();
const identity = identityQuery.data;
const currentPubkey = identity?.pubkey ?? null;
const starterChannelsCommunityScope = activeCommunity?.relayUrl ?? null;
const starterChannelsInitPromisesRef = React.useRef(
new Map<string, Promise<ChannelInitResult>>(),
);
const [isCompletingStarterSetup, setIsCompletingStarterSetup] =
React.useState(false);
const identityLost = identity?.lost === true;
// Keyring unreachable at boot — the real key is still in the OS keyring but
// the session cannot access it. No in-app recovery is possible; the user
// must unlock the keyring externally and relaunch. Mutually exclusive with lost.
const identityLocked = identity?.locked === true;
// Boot-time Phase 2 reset failed — wipe was attempted but verification failed.
// The sentinel is preserved so the next relaunch retries automatically.
const identityResetFailed = identity?.resetFailed === true;
// Sticky boot fact: once identity was lost at boot, this remains true for the
// entire session. Per-component state in OnboardingFlow cannot carry this
// because the flow remounts when pubkey changes after recovery.
const [bootedLost, setBootedLost] = React.useState(false);
React.useEffect(() => {
if (identityLost) setBootedLost(true);
}, [identityLost]);
// Sticky boot fact: once identity was locked at boot, this remains true for
// the entire session. After import_identity clears the locked flag, the
// relaunchRequired derivation uses this to force the relaunch screen.
const [bootedLocked, setBootedLocked] = React.useState(false);
React.useEffect(() => {
if (identityLocked) setBootedLocked(true);
}, [identityLocked]);
const profileQuery = useProfileQuery(
!identityLost && !identityLocked && identityQuery.status === "success",
);
const onboardingGate = useFirstRunOnboardingGate({
currentPubkey,
identityIsFetching: identityQuery.fetchStatus === "fetching",
identityLost,
identityStatus: identityQuery.status,
isSharedIdentity,
profileHasEvent: profileQuery.data?.hasProfileEvent,
profileIsFetching: profileQuery.fetchStatus === "fetching",
profileStatus: profileQuery.status,
});
const gateComplete = onboardingGate.complete;
const starterChannelsFocusIntentRef = React.useRef(
new Map<string, boolean>(),
);
const requestStarterChannels = React.useCallback(
(focus: boolean): Promise<ChannelInitResult> => {
if (!currentPubkey || !starterChannelsCommunityScope) {
return Promise.resolve({ ok: true });
}
const starterChannelsInitKey = `${starterChannelsCommunityScope}:${currentPubkey}`;
const currentPromise = starterChannelsInitPromisesRef.current.get(
starterChannelsInitKey,
);
if (currentPromise) {
// A focus=true request must not be swallowed behind an in-flight
// focus=false promise. Upgrade the intent: when the background
// promise resolves, chain a focus-only follow-up.
if (
focus &&
!starterChannelsFocusIntentRef.current.get(starterChannelsInitKey)
) {
starterChannelsFocusIntentRef.current.set(
starterChannelsInitKey,
true,
);
return currentPromise.then((result) => {
if (!result.ok) return result;
return initializeStarterChannels(queryClient, {
focus: true,
pubkey: currentPubkey,
communityScope: starterChannelsCommunityScope,
});
});
}
return currentPromise;
}
if (focus) {
starterChannelsFocusIntentRef.current.set(starterChannelsInitKey, true);
}
const promise = initializeStarterChannels(queryClient, {
focus,
pubkey: currentPubkey,
communityScope: starterChannelsCommunityScope,
}).finally(() => {
starterChannelsInitPromisesRef.current.delete(starterChannelsInitKey);
starterChannelsFocusIntentRef.current.delete(starterChannelsInitKey);
});
starterChannelsInitPromisesRef.current.set(
starterChannelsInitKey,
promise,
);
return promise;
},
[currentPubkey, queryClient, starterChannelsCommunityScope],
);
React.useEffect(() => {
if (
onboardingGate.stage !== "ready" ||
!currentPubkey ||
!starterChannelsCommunityScope ||
!readOnboardingCompletion(currentPubkey) ||
hasEnsuredWelcomeChannel(currentPubkey, starterChannelsCommunityScope)
) {
return;
}
void requestStarterChannels(false);
}, [
currentPubkey,
onboardingGate.stage,
requestStarterChannels,
starterChannelsCommunityScope,
]);
const showStarterRetryToast = React.useCallback(
(reason: string) => {
toast.error("Couldn't set up starter channels", {
id: STARTER_CHANNEL_SETUP_TOAST_ID,
action: {
label: "Retry",
onClick: (event) => {
event.preventDefault();
void requestStarterChannels(true).then((result) => {
if (!result.ok) {
window.setTimeout(
// Sonner dismisses an action toast as its click resolves, so
// recreate a failed retry after that dismissal completes.
() => showStarterRetryToast(result.reason),
0,
);
return;
}
toast.dismiss(STARTER_CHANNEL_SETUP_TOAST_ID);
});
},
},
description: reason,
});
},
[requestStarterChannels],
);
const completeAndShowWelcome = React.useCallback(() => {
setIsCompletingStarterSetup(true);
void requestStarterChannels(true).then(async (starterResult) => {
await refreshChannelsCache(queryClient);
gateComplete();
setIsCompletingStarterSetup(false);
if (starterResult.focusChannelId) {
window.location.hash = `/channels/${encodeURIComponent(
starterResult.focusChannelId,
)}`;
}
if (!starterResult.ok) {
showStarterRetryToast(starterResult.reason);
}
});
}, [
gateComplete,
queryClient,
requestStarterChannels,
showStarterRetryToast,
]);
const flow = {
actions: {
complete: completeAndShowWelcome,
skipForNow: onboardingGate.skipForNow,
},
initialProfile: {
profile: profileQuery.data,
},
};
// Recovery completed this boot: force a relaunch screen regardless of any
// other gate state. Backend startup routines (event sync, agent restore,
// pending-event flush) were skipped for the ephemeral key and cannot restart
// in-process, so nothing else can proceed until the app restarts.
const relaunchRequired =
((bootedLost && !identityLost) || (bootedLocked && !identityLocked)) &&
identityQuery.status === "success";
return {
currentPubkey,
flow,
identityLost,
// reset-failed is the highest-precedence stage: a failed boot-time reset
// means identity resolution was skipped entirely. Nothing can proceed until
// the user relaunches and the wipe retries.
stage:
identityResetFailed && identityQuery.status === "success"
? ("reset-failed" as const)
: identityLocked && identityQuery.status === "success"
? ("keyring-locked" as const)
: relaunchRequired
? ("relaunch-required" as const)
: isCompletingStarterSetup
? ("blocking" as const)
: onboardingGate.stage,
};
}