storage.ts's registry gives each key a kind (pref, device, view, cache, public); a key without one does not compile. A contract test refuses a key spelled out elsewhere, an account preference read or written straight from storage instead of through usePref, and an e2e seed of a key nobody reads. The keys the next account's wipe keeps are the public ones. The seven preferences kept only in the browser move onto usePref: start on Home, the onboarding dismissal, the release banner's last version, the channel manager's layout, the Plex playlist layout, the playlist rail's sort and channel discovery's sort. usePref reads an older bare-string copy, and a value only this browser held is saved to the server once, so the account's other devices get it. Performance mode is the device's: it is neither read from the account nor saved to it.
329 lines
13 KiB
TypeScript
329 lines
13 KiB
TypeScript
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 } from "lucide-react";
|
|
import { YouTubeIcon } from "../lib/brandIcons";
|
|
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 (
|
|
<div className="flex gap-2.5 rounded-xl border border-warn/30 bg-warn/5 p-3 text-left">
|
|
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-warn" />
|
|
<p className="text-xs leading-relaxed text-fg/80">
|
|
<Trans
|
|
i18nKey="onboarding.consent"
|
|
components={[
|
|
<span className="font-medium" />,
|
|
<span className="font-medium" />,
|
|
<span className="font-medium" />,
|
|
<a
|
|
href="https://myaccount.google.com/permissions"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-accent hover:underline"
|
|
/>,
|
|
]}
|
|
/>
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Dots({ index, total }: { index: number; total: number }) {
|
|
return (
|
|
<div className="flex justify-center gap-1.5">
|
|
{Array.from({ length: total }).map((_, i) => (
|
|
<span
|
|
key={i}
|
|
className={`h-1.5 rounded-full transition-all ${
|
|
i === index ? "w-5 bg-accent" : "w-1.5 bg-border"
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function OnboardingWizard({
|
|
me,
|
|
onClose,
|
|
}: {
|
|
me: Me;
|
|
/** `dismiss`: the essential read step was skipped, so the wizard stops opening by itself. */
|
|
onClose: (dismiss: boolean) => 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();
|
|
onClose(!me.can_read);
|
|
};
|
|
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.
|
|
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 <body>, nothing announced a dialog, and Tab past the last control walked
|
|
// into the app behind the scrim — where the focus ring is invisible. The trap
|
|
// and the roles come from the same place `ui/Modal` gets them, not from a second hand-written copy.
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
const rootRef = useRef<HTMLDivElement>(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 (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. Once triggered, the mutation itself says
|
|
// which (pending / success / error) — the trigger ref is the effect's once-only latch, never read
|
|
// here (a ref read in render made the React Compiler skip the wizard).
|
|
const importing =
|
|
me.can_read &&
|
|
!importSubs.isError &&
|
|
(importSubs.isPending || (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 (
|
|
<Overlay>
|
|
<div ref={rootRef} className="fixed inset-0 z-overlay grid place-items-center p-4">
|
|
<div
|
|
className="absolute inset-0 bg-black/50 animate-[overlayIn_0.2s_ease]"
|
|
onClick={close}
|
|
/>
|
|
<div
|
|
ref={panelRef}
|
|
tabIndex={-1}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby={titleId}
|
|
className={`${SURFACE.panel} relative w-[min(94vw,460px)] p-7 text-center outline-none animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]`}
|
|
>
|
|
<IconButton
|
|
size="md"
|
|
onClick={close}
|
|
className="absolute top-3 right-3"
|
|
label={t("onboarding.close")}
|
|
>
|
|
<X />
|
|
</IconButton>
|
|
|
|
{step === "read" && (
|
|
<>
|
|
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
|
<YouTubeIcon className="w-6 h-6" />
|
|
</div>
|
|
<h2 id={titleId} className="text-lg font-semibold">
|
|
{t("onboarding.read.title")}
|
|
</h2>
|
|
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
|
|
<Trans
|
|
i18nKey="onboarding.read.body"
|
|
components={[<span className="text-fg/90" />]}
|
|
/>
|
|
</p>
|
|
<ConsentHeadsUp />
|
|
<div className="mt-5 flex flex-col gap-2">
|
|
<Button size="lg" icon={<YouTubeIcon />} onClick={() => beginGrant("read")}>
|
|
{t("onboarding.read.connect")}
|
|
</Button>
|
|
<Button variant="ghost" size="md" onClick={close}>
|
|
{t("onboarding.read.skip")}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Read granted: show import progress before the optional write step. */}
|
|
{me.can_read && importing && (
|
|
<>
|
|
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
|
<Loader2 className="w-6 h-6 animate-spin" />
|
|
</div>
|
|
<h2 id={titleId} className="text-lg font-semibold">
|
|
{t("onboarding.importing.title")}
|
|
</h2>
|
|
<p className="text-sm text-muted leading-relaxed mt-2 mb-2">
|
|
{t("onboarding.importing.body")}
|
|
</p>
|
|
</>
|
|
)}
|
|
|
|
{step === "write" && !importing && (
|
|
<>
|
|
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
|
<Check className="w-6 h-6" />
|
|
</div>
|
|
<h2 id={titleId} className="text-lg font-semibold">
|
|
{t("onboarding.write.title")}
|
|
</h2>
|
|
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
|
|
{importSubs.isError ? (
|
|
<>{t("onboarding.write.importFailed")}</>
|
|
) : (
|
|
<>
|
|
{t("onboarding.write.feedReady", {
|
|
channels: myStatus.data
|
|
? t("onboarding.write.feedReadyChannels", {
|
|
count: myStatus.data.channels_total,
|
|
})
|
|
: "",
|
|
})}
|
|
</>
|
|
)}
|
|
{!importSubs.isError && (
|
|
<Trans
|
|
i18nKey="onboarding.write.rationale"
|
|
components={[<span className="text-fg/90" />]}
|
|
/>
|
|
)}
|
|
</p>
|
|
{importSubs.isError && (
|
|
<Button
|
|
variant="outline"
|
|
icon={<RefreshCw />}
|
|
loading={importSubs.isPending}
|
|
onClick={() => importSubs.mutate()}
|
|
className="mb-3"
|
|
>
|
|
{t("onboarding.write.retryImport")}
|
|
</Button>
|
|
)}
|
|
<ConsentHeadsUp />
|
|
<div className="mt-5 flex flex-col gap-2">
|
|
<Button size="lg" icon={<ShieldCheck />} onClick={() => beginGrant("write")}>
|
|
{t("onboarding.write.enableUnsubscribe")}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="md"
|
|
onClick={() => {
|
|
endOnboarding();
|
|
onClose(false);
|
|
}}
|
|
>
|
|
{t("onboarding.write.keepReadOnly")}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{step === "done" && !importing && (
|
|
<>
|
|
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
|
<Check className="w-6 h-6" />
|
|
</div>
|
|
<h2 id={titleId} className="text-lg font-semibold">
|
|
{t("onboarding.done.title")}
|
|
</h2>
|
|
<p className="text-sm text-muted leading-relaxed mt-2 mb-5">
|
|
{t("onboarding.done.body")}
|
|
</p>
|
|
<Button
|
|
size="lg"
|
|
fullWidth
|
|
onClick={() => {
|
|
endOnboarding();
|
|
onClose(false);
|
|
}}
|
|
>
|
|
{t("onboarding.done.done")}
|
|
</Button>
|
|
</>
|
|
)}
|
|
|
|
<div className="mt-6">
|
|
<Dots index={stepIndex} total={3} />
|
|
</div>
|
|
</div>
|
|
{/* Toasts raised while the wizard is up (the connection notice among them) show above its
|
|
scrim, not under it — see ui/Toaster. */}
|
|
<Toaster overlay order={overlayOrder} />
|
|
</div>
|
|
</Overlay>
|
|
);
|
|
}
|