import { useEffect, useId, useRef } from "react"; import { Trans, useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react"; import { api, type Me } from "../lib/api"; import { beginGrant, endOnboarding } from "../lib/onboarding"; import { Button } from "./ui/Button"; import Overlay from "./ui/Overlay"; import Toaster from "./ui/Toaster"; import { useOverlayOrder } from "./ui/overlayOrder"; import { useLayer } from "../lib/layerStack"; import { trapTab, useDialogChrome } from "./ui/dialogFocus"; import { IconButton } from "./ui/IconButton"; import { SURFACE } from "./ui/surfaces"; // A first-login wizard that walks the user through granting YouTube access one scope at a // time, each with a plain explanation and an up-front heads-up about Google's "unverified // app" screen. Sign-in itself only asks for name/email (no warning); YouTube access is // always an explicit, informed opt-in here. // // The visible step is derived from what's already granted, so the wizard resumes correctly // after each redirect: no read -> "read", read but no write -> "write", both -> "done". // Right after read is granted, it auto-imports the user's subscriptions so they land on a // populated feed (channels already in the shared catalog appear instantly). // A small reusable heads-up so the Google consent warning never feels like a surprise. function ConsentHeadsUp() { return (

, , , , ]} />

); } function Dots({ index, total }: { index: number; total: number }) { return (
{Array.from({ length: total }).map((_, i) => ( ))}
); } export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () => void }) { const { t } = useTranslation(); const qc = useQueryClient(); const importTriggered = useRef(false); // Once we have read access, find out whether any subscriptions are imported yet. const myStatus = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus, enabled: me.can_read, }); const importSubs = useMutation({ mutationFn: () => api.syncSubscriptions(), onSuccess: () => { // Feed/channels/status now have content — refresh the app behind the wizard. qc.invalidateQueries({ queryKey: qk.feed() }); qc.invalidateQueries({ queryKey: qk.feedSummary() }); qc.invalidateQueries({ queryKey: qk.channels() }); qc.invalidateQueries({ queryKey: qk.myStatus() }); }, // The step itself says the import failed and offers a retry button — no toast on top. meta: { errorShownInline: true }, }); const { mutate: startImport } = importSubs; // First time we see read access with zero imported channels, pull the user's // subscriptions automatically so they don't have to hunt for a button. useEffect(() => { if ( me.can_read && myStatus.data && myStatus.data.channels_total === 0 && !importTriggered.current ) { importTriggered.current = true; startImport(); } // `mutate` is stable (react-query memoises it) while the mutation object around it is not, so // the effect can name what it calls. The ref keeps it to once regardless. }, [me.can_read, myStatus.data, startImport]); // Closing without finishing the essential read step suppresses future auto-popups // (it stays reachable from Settings). Once read is granted, closing is just "done". const close = () => { endOnboarding(!me.can_read); onClose(); }; const overlayOrder = useOverlayOrder(); // this overlay's toast-host rank (see ui/Toaster) // A layer in the shared stack, so Escape closes the wizard only when it is on top: a raw document // listener ran BEFORE the error dialog's (a sync that failed inside the wizard opens one), and one // Esc closed the error AND ended onboarding, skipping the write-scope step (deep-review round 4). const layer = useLayer(true, close); // The rest of the dialog contract. This is a modal overlay in every way that matters to a user, but // it renders its own shell (it is not a titled dialog with a header row), so Escape ordering was all // it had: focus started on , nothing announced a dialog, and Tab past the last control walked // into the app behind the scrim — where the focus ring is invisible (deep-review round 5). The trap // and the roles come from the same place `ui/Modal` gets them, not from a second hand-written copy. const panelRef = useRef(null); const rootRef = useRef(null); const titleId = useId(); useDialogChrome(panelRef, rootRef); useEffect(() => { const onKey = (e: KeyboardEvent) => { // Only while nothing that owns the keyboard is above us: an error dialog opened from inside the // wizard owns the Tab, a hover card under the pointer does not. if (e.key !== "Tab" || layer.blockingAbove()) return; const panel = panelRef.current; // The SAME three arguments `ui/Modal` passes, for the same two reasons — and this call had // neither of them (round 6, on the commit that extracted the helper so the two shells could // not disagree). Scope is the whole dialog, so the toast host beside the panel stays in the // Tab cycle (a sticky connection notice's ✕ was unreachable from the keyboard); `container` // is the tabIndex=-1 panel, because `panel.contains(panel)` is true, so focus sitting ON the // panel — where a click on its dead space puts it — matched no branch at all and a Shift+Tab // walked out into the app behind the scrim. if (panel) trapTab(e, rootRef.current ?? panel, panel); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [layer]); // Are we currently importing (or about to)? Show a progress screen instead of the // "you're connected" step until the first import settles. const importing = me.can_read && !importSubs.isError && (importSubs.isPending || (importTriggered.current && !importSubs.isSuccess) || (myStatus.data?.channels_total === 0 && !importSubs.isSuccess)); const step = !me.can_read ? "read" : !me.can_write ? "write" : "done"; const stepIndex = step === "read" ? 0 : step === "write" ? 1 : 2; return (
{step === "read" && ( <>

{t("onboarding.read.title")}

]} />

)} {/* Read granted: show import progress before the optional write step. */} {me.can_read && importing && ( <>

{t("onboarding.importing.title")}

{t("onboarding.importing.body")}

)} {step === "write" && !importing && ( <>

{t("onboarding.write.title")}

{importSubs.isError ? ( <>{t("onboarding.write.importFailed")} ) : ( <> {t("onboarding.write.feedReady", { channels: myStatus.data ? t("onboarding.write.feedReadyChannels", { count: myStatus.data.channels_total, }) : "", })} )} {!importSubs.isError && ( ]} /> )}

{importSubs.isError && ( )}
)} {step === "done" && !importing && ( <>

{t("onboarding.done.title")}

{t("onboarding.done.body")}

)}
{/* Toasts raised while the wizard is up (the connection notice among them) show above its scrim, not under it — see ui/Toaster. */}
); }