Closes the burn-down. Hook suppressions 113 -> 34, `exhaustive-deps` 45 -> 2 (the target was <10), and every one of the 34 survivors is an inline directive NAMING the invariant it protects. The suppressions file is deleted, because a bulk file hides the next violation instead of answering it — and it cannot hold a warning-level rule at all. The sprint plan's three verdicts missed the answer that turned out to be the most common one: adjust the state DURING RENDER. React re-runs the component and discards that pass rather than committing it, so the stale frame an effect-based reset paints does not exist. Applied to the Plex player's up-next card, the playlist view reset, the feed's optimistic overrides, the channel banner's retry count, the download center's tab guard, the Plex search highlight, the comfort-pin latch, Welcome's per-view outcome and the card pager's page clamp. It is legal only for a component's own state — PlexBrowse's language commit resets PlexProvider's filters, so that one stays an effect and says why. Mirrors of a prop or query became derived values or `draft ?? server`: - `downloadRow` had ui/Avatar's defect exactly — a boolean "poster failed" that an effect reset, in rows recycled down a virtualized list, so one job's missing poster painted over the next one's for a frame. Keyed by URL now. - Scheduler's two number editors held a mirror an effect re-synced, on a page that POLLS — the shape a poll can overwrite mid-edit. A draft while editing, the server's value otherwise; six tests where there were none, the one behavioural change (the save button stops being dirty at commit rather than at the echo) mutation-checked. - SetupWizard's "no token" and WatchPage's "gone" are facts about the URL, so they are seeded and derived rather than pushed into state by an effect that had to commit first. Elsewhere: PlexInfo's rating chip was a component declared during render — a new component type every render, so React remounted the subtree and dropped the focus on its filter button. Hoisted. Feed's undo action and PlexPlayer's session reclaim both re-entered their own useCallback, which the checker reads as a forward reference; both go through the ref idiom this file already uses. BootGate's dep list was load-bearing for correctness — a re-run meant `location.reload()` — so the "already adopted THIS identity" comparison now enforces it in code, with a test that fails without it (the existing reload tests all install their spy too late to catch it). One correction to the batch before this: keying ChatThread's and Messages' decrypt passes on `q.data` instead of `q.dataUpdatedAt` was wrong. react-query's structural sharing makes it safe in production, but the component must not melt down when something hands back a fresh object per render — and Messages.keyboard.dom.test does exactly that, so the effect span the suite. Both are back on the fetch as the trigger with the payload behind a ref, which is honest AND loop-proof; the replaced comment had warned about precisely this. CLAUDE.md gains four canonical-patterns rows for the above, and its "React 18" line is now true.
320 lines
13 KiB
TypeScript
320 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, 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";
|
|
|
|
// 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; 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 <body>, 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<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 (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 (
|
|
<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="glass relative w-[min(94vw,460px)] rounded-2xl p-7 text-center outline-none animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]"
|
|
>
|
|
<button
|
|
onClick={close}
|
|
className="absolute top-3 right-3 p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition"
|
|
aria-label={t("onboarding.close")}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
|
|
{step === "read" && (
|
|
<>
|
|
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
|
<Youtube 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
|
|
onClick={() => beginGrant("read")}
|
|
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold btn-primary transition"
|
|
>
|
|
<Youtube className="w-4 h-4" /> {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
|
|
onClick={() => importSubs.mutate()}
|
|
className="mb-3 inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl text-sm bg-card border border-border hover:border-accent transition"
|
|
>
|
|
<RefreshCw className="w-4 h-4" /> {t("onboarding.write.retryImport")}
|
|
</button>
|
|
)}
|
|
<ConsentHeadsUp />
|
|
<div className="mt-5 flex flex-col gap-2">
|
|
<button
|
|
onClick={() => beginGrant("write")}
|
|
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold btn-primary transition"
|
|
>
|
|
<ShieldCheck className="w-4 h-4" /> {t("onboarding.write.enableUnsubscribe")}
|
|
</button>
|
|
<Button
|
|
variant="ghost"
|
|
size="md"
|
|
onClick={() => {
|
|
endOnboarding(false);
|
|
onClose();
|
|
}}
|
|
>
|
|
{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
|
|
onClick={() => {
|
|
endOnboarding(false);
|
|
onClose();
|
|
}}
|
|
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold btn-primary transition w-full"
|
|
>
|
|
{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>
|
|
);
|
|
}
|