refactor(frontend): every stored key declares its kind, and preferences follow the account

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.
This commit is contained in:
2026-10-20 23:04:48 +02:00
parent d6dada7fe5
commit f21cdfa156
29 changed files with 749 additions and 220 deletions
+6 -3
View File
@@ -7,8 +7,8 @@ import { UserPlus } from "lucide-react";
import { api, type DiscoveredChannel } from "../lib/api";
import { LS, ownKey } from "../lib/storage";
import { formatCountOrDash } from "../lib/format";
import { sortRows } from "../lib/columnSort";
import { useSortState } from "../lib/useSortState";
import { hydrateSort, sortRows, type SortState } from "../lib/columnSort";
import { usePref } from "../lib/usePref";
import { useCardPager } from "../lib/useCardPager";
import { subsColumn } from "./channelColumns";
import { notify } from "../lib/notifications";
@@ -48,7 +48,10 @@ export default function ChannelDiscovery({
// The table/cards density is shared with the Subscriptions tab (ChannelsProvider). The sort is
// this tab's own (its columns differ), lifted out of the table so the card layout shares it.
const layout = useChannelLayout();
const [sort, setSort] = useSortState(LS.channelDiscoverySort);
const [sort, setSort] = usePref<SortState>("channelDiscoverySort", null, {
localKey: LS.channelDiscoverySort,
parse: (raw) => hydrateSort(raw == null ? null : JSON.stringify(raw), []),
});
const query = useQuery({
queryKey: qk.discoveredChannels(),
@@ -6,7 +6,8 @@
//
// Uses REAL storage (jsdom localStorage/sessionStorage) with account 1's local state claimed, so
// useAccountPersistedState / useSortState / useFilterState all resolve their account-scoped keys; only
// navigationContext (the sole runtime dependency) is mocked, pinned to the channels page.
// navigationContext is mocked, pinned to the channels page. The layout is an account preference
// (usePref), read from the `me` cache BootGate fills before the app mounts.
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act, render as renderRoot } from "@testing-library/react";
import { useEffect, useEffectEvent, type ReactNode } from "react";
@@ -17,11 +18,19 @@ vi.mock("./navigationContext", () => ({
}));
import { ChannelsProvider } from "./ChannelsProvider";
import { useChannels, useChannelsActions } from "./channelsContext";
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
import { claimLocalData } from "../lib/storage";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
let qc = new QueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<ChannelsProvider>{children}</ChannelsProvider>
<QueryClientProvider client={qc}>
<ChannelsProvider>{children}</ChannelsProvider>
</QueryClientProvider>
);
const withClient = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
const render = () => renderHook(() => ({ ...useChannels(), ...useChannelsActions() }), { wrapper });
const VIEW_KEY = "siftlode.channelsView"; // the account's (claimed in beforeEach)
@@ -29,6 +38,8 @@ const VIEW_KEY = "siftlode.channelsView"; // the account's (claimed in beforeEac
beforeEach(() => {
localStorage.clear();
claimLocalData(1); // the browser holds account 1's state
qc = new QueryClient();
qc.setQueryData(qk.me(), { id: 1, preferences: {} });
// Reset BOTH the history state and the query string: setView only owns an entry on the channels page,
// and the URL mirror writes ctab/csort/cf — a stale key left by a prior test would be read back by
// this provider's mount-time URL seed and change the starting tab.
@@ -166,6 +177,7 @@ describe("ChannelsProvider — the focus-a-channel intent", () => {
<Probe />
<Harness mounted />
</ChannelsProvider>,
{ wrapper: withClient },
);
act(() => api.focusChannel("Kurzgesagt"));
@@ -202,6 +214,7 @@ describe("ChannelsProvider — the focus-a-channel intent", () => {
<Probe />
<Harness mounted />
</ChannelsProvider>,
{ wrapper: withClient },
);
act(() => api.focusChannel("Kurzgesagt"));
@@ -210,3 +223,14 @@ describe("ChannelsProvider — the focus-a-channel intent", () => {
expect(api.search).toBe("Kurzgesagt");
});
});
describe("ChannelsProvider — the layout", () => {
// The table or the cards is a choice about the account, so it follows it to every device: the
// server's word wins over what this browser last showed.
it("is the account's preference, over this browser's copy", () => {
localStorage.setItem("siftlode.channelLayout", "table");
qc.setQueryData(qk.me(), { id: 1, preferences: { channelLayout: "cards" } });
const { result } = renderHook(useChannelLayout, { wrapper });
expect(result.current).toBe("cards");
});
});
+8 -4
View File
@@ -12,6 +12,7 @@ import { parseChannelLayout } from "../lib/channelLayout";
import { hydrateSort } from "../lib/columnSort";
import { useLatest } from "../lib/useLatest";
import { useSortState } from "../lib/useSortState";
import { usePref } from "../lib/usePref";
import { useFilterState } from "../lib/useFilterState";
import { useUrlMirror } from "../lib/useUrlMirror";
import { rememberNavPosition } from "../lib/history";
@@ -100,12 +101,15 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
// The subscribed table's column-filter map, owned here (lifted out of DataTable) so the top status
// chips, the Sync column funnel, and the card layout share one state; useFilterState persists it.
const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip);
// view + layout are persisted tabs (useAccountPersistedState = the shared account-scoped tab-state hook).
// Stored un-clamped; clampView/parseChannelLayout heal them for display. setView layers the history
// logic (below) on top of the raw setter.
// The view is this browser's (useAccountPersistedState), the layout an account preference
// (usePref). Stored un-clamped; clampView/parseChannelLayout heal them for display. setView layers
// the history logic (below) on top of the raw setter.
const [viewRaw, setViewRaw] = useAccountPersistedState(LS.channelsView, "subscribed");
const view = clampView(viewRaw);
const [layoutRaw, setLayout] = useAccountPersistedState(LS.channelLayout, "table");
const [layoutRaw, setLayout] = usePref<string>("channelLayout", "table", {
localKey: LS.channelLayout,
parse: (raw) => (typeof raw === "string" ? raw : "table"),
});
const layout = parseChannelLayout(layoutRaw);
// Sort is owned here (lifted out of DataTable) so the card layout shares it; ColumnSortControl heals
// it against the live columns for display, so the provider stores it un-healed (no options passed).
@@ -32,7 +32,8 @@ import { PageHeading } from "./ui/Headings";
import { qk } from "../lib/queryKeys";
import { claimLocalData, LS, setAccountRaw } from "../lib/storage";
import { PAGE_KEY, START_ON_HOME } from "./navigationInit";
import { api } from "../lib/api";
import { PAGE_KEY } from "./navigationInit";
/** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs
* to this document, nearest first. jsdom has no Navigation API; Chrome's says this. */
@@ -263,7 +264,12 @@ describe("NavigationProvider · Back", () => {
});
it("a remembered retired page opens its tab", () => {
setAccountRaw(LS.startOnHome, START_ON_HOME.off);
qc.setQueryData(qk.me(), {
id: 1,
role: "admin",
plex_enabled: true,
preferences: { startOnHome: false },
});
setAccountRaw(PAGE_KEY, "logs");
window.history.replaceState(null, "", "/");
const { result } = render();
@@ -271,6 +277,19 @@ describe("NavigationProvider · Back", () => {
expect(new URLSearchParams(window.location.search).get("at")).toBe("logs");
});
// Start-on-Home is the account's preference. An opt-out this browser kept before the server held it
// (the string "0") still counts, and reaches the server for the account's other devices.
it("takes this browser's earlier opt-out of Home, and saves it to the account", () => {
const save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
qc.setQueryData(qk.me(), { id: 1, role: "admin", plex_enabled: true, preferences: {} });
setAccountRaw(LS.startOnHome, "0");
setAccountRaw(PAGE_KEY, "plex");
window.history.replaceState(null, "", "/");
const { result } = render();
expect(result.current.page).toBe("plex");
expect(save).toHaveBeenCalledWith({ startOnHome: false });
});
it("opens a retired page's tab when Back lands on its entry", () => {
const { result } = render();
act(() => result.current.setPage("plex"));
+13 -2
View File
@@ -15,7 +15,15 @@ import {
rememberNavPosition,
} from "../lib/history";
import { reachablePage } from "../lib/modules";
import { channelViewFrom, loadInitialPage, PAGE_KEY, type ChannelView } from "./navigationInit";
import {
channelViewFrom,
loadInitialPage,
PAGE_KEY,
START_ON_HOME_OPTIONS,
START_ON_HOME_PREF,
type ChannelView,
} from "./navigationInit";
import { usePref } from "../lib/usePref";
import {
NavigationActionsContext,
NavigationStateContext,
@@ -32,7 +40,10 @@ import {
export function NavigationProvider({ children }: { children: ReactNode }) {
const { t, i18n } = useTranslation();
const [chosenPage, setPageState] = useState<Page>(loadInitialPage);
// Held here as well as in Settings: this is the instance mounted on every boot, so a value only
// this browser held reaches the server at once.
const [startOnHome] = usePref(START_ON_HOME_PREF, true, START_ON_HOME_OPTIONS);
const [chosenPage, setPageState] = useState<Page>(() => loadInitialPage(startOnHome));
// The page on screen: the chosen one when this account may open it, else the feed — derived, so a
// link, a Back and a role taken away mid-session all land the same way, and the rail, the content
// and the address bar (stamped from it below) agree.
@@ -33,7 +33,7 @@ afterEach(() => {
});
describe("the onboarding wizard's Escape", () => {
const mount = (onClose: () => void, dialog: boolean) =>
const mount = (onClose: (dismiss: boolean) => void, dialog: boolean) =>
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
@@ -51,7 +51,8 @@ describe("the onboarding wizard's Escape", () => {
const onClose = vi.fn();
mount(onClose, false);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
// Read was never granted: leaving now stops the wizard opening by itself.
expect(onClose).toHaveBeenCalledExactlyOnceWith(true);
});
it("leaves the wizard alone while a dialog is open over it", () => {
+14 -7
View File
@@ -65,7 +65,14 @@ function Dots({ index, total }: { index: number; total: number }) {
);
}
export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () => void }) {
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);
@@ -110,8 +117,8 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
// 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();
endOnboarding();
onClose(!me.can_read);
};
const overlayOrder = useOverlayOrder(); // this overlay's toast-host rank (see ui/Toaster)
@@ -274,8 +281,8 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
variant="ghost"
size="md"
onClick={() => {
endOnboarding(false);
onClose();
endOnboarding();
onClose(false);
}}
>
{t("onboarding.write.keepReadOnly")}
@@ -299,8 +306,8 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
size="lg"
fullWidth
onClick={() => {
endOnboarding(false);
onClose();
endOnboarding();
onClose(false);
}}
>
{t("onboarding.done.done")}
+6 -5
View File
@@ -9,7 +9,8 @@ import { compareText } from "../lib/collate";
import { formatRuntime } from "../lib/format";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
import { LS } from "../lib/storage";
import { usePref } from "../lib/usePref";
import { type PanelLayout } from "../lib/panelLayout";
import FilterRail from "./FilterRail";
import { Button } from "./ui/Button";
@@ -65,7 +66,10 @@ export default function PlaylistsRail({
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
const [plSort, setPlSort] = useState<PlSort>(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT));
const [plSort, setPlSort] = usePref<PlSort>("plSort", PL_SORT_DEFAULT, {
localKey: LS.plSort,
parse: (raw) => ({ ...PL_SORT_DEFAULT, ...(raw && typeof raw === "object" ? raw : {}) }),
});
const plSortOptions: SortOption[] = [
{ key: "custom", label: t("playlists.railSortCustom"), directionless: true },
{ key: "name", label: t("playlists.railSortName"), defaultDir: "asc" },
@@ -76,9 +80,6 @@ export default function PlaylistsRail({
// default: a rail that reshuffles itself while you look at it costs more than it saves.
{ key: "recent", label: t("playlists.railSortRecent"), defaultDir: "desc" },
];
useEffect(() => {
writeAccount(LS.plSort, plSort);
}, [plSort]);
const [newName, setNewName] = useState("");
const [syncing, setSyncing] = useState(false);
const plActions = usePlaylistActions();
+7 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useMemo, useRef, useState, type ReactNode } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
@@ -27,7 +27,8 @@ import { notifyActionError } from "../lib/actionErrors";
import { navItemAttrs, useListNav } from "../lib/listNav";
import { toggleWatchState } from "../lib/plexWatch";
import { formatRuntime } from "../lib/format";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { LS } from "../lib/storage";
import { usePref } from "../lib/usePref";
import { useConfirm } from "./confirmContext";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
@@ -424,12 +425,10 @@ export default function PlexPlaylistView({
const [renaming, setRenaming] = useState(false);
const { target: renameTarget, arm: armRenameReturn } = useReturnFocus(renaming);
const [name, setName] = useState("");
const [layout, setLayout] = useState<Layout>(() =>
getAccountRaw(LS.plexPlaylistLayout) === "tree" ? "tree" : "accordion",
);
useEffect(() => {
setAccountRaw(LS.plexPlaylistLayout, layout);
}, [layout]);
const [layout, setLayout] = usePref<Layout>("plexPlaylistLayout", "accordion", {
localKey: LS.plexPlaylistLayout,
parse: (raw) => (raw === "tree" ? "tree" : "accordion"),
});
// Show groups start collapsed (compact headers) so a long series doesn't force endless scrolling;
// seasons start expanded once their show is opened. Both are just local view state.
const [expandedShows, setExpandedShows] = useState<Set<string>>(new Set());
@@ -2,8 +2,9 @@
//
// The Settings prefs are seeded from the account's server prefs in the `me` cache — BootGate mounts the
// tree only once it is there. The adopt effect this replaced corrected them AFTER mounting; with it
// gone, a seed that read only this browser's copy would never show a performance mode or hints setting
// changed on another device. And the seed is not an edit: it must not be saved straight back.
// gone, a seed that read only this browser's copy would never show a hints setting changed on another
// device. And the seed is not an edit: it must not be saved straight back. Performance mode is the
// exception: it is this device's, so the server's word on it is not taken and a change is not sent.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -14,16 +15,31 @@ import { claimLocalData, LS } from "../lib/storage";
import { qk } from "../lib/queryKeys";
const seen: { perf: boolean; hints: boolean }[] = [];
const controls: ReturnType<typeof usePrefs>[] = [];
function Probe() {
const { perf, hints } = usePrefs();
seen.push({ perf, hints });
const prefs = usePrefs();
controls.push(prefs);
seen.push({ perf: prefs.perf, hints: prefs.hints });
return null;
}
function mount(preferences: Record<string, unknown>) {
const qc = new QueryClient();
qc.setQueryData(qk.me(), { id: 5, preferences });
render(
<QueryClientProvider client={qc}>
<PrefsProvider>
<Probe />
</PrefsProvider>
</QueryClientProvider>,
);
}
beforeEach(() => {
localStorage.clear();
claimLocalData(5);
seen.length = 0;
controls.length = 0;
vi.useFakeTimers();
});
afterEach(() => {
@@ -34,24 +50,38 @@ afterEach(() => {
describe("PrefsProvider seed", () => {
it("renders the account's server prefs from the first render, and does not save them back", () => {
localStorage.setItem(LS.perfMode, "0"); // this browser last had it off
localStorage.setItem(LS.hints, "1");
const save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
const qc = new QueryClient();
qc.setQueryData(qk.me(), { id: 5, preferences: { performanceMode: true, hints: false } });
render(
<QueryClientProvider client={qc}>
<PrefsProvider>
<Probe />
</PrefsProvider>
</QueryClientProvider>,
);
mount({ hints: false });
expect(seen[0]).toEqual({ perf: true, hints: false });
expect(seen[0]?.hints).toBe(false);
// past the auto-save debounce
act(() => {
vi.advanceTimersByTime(1000);
});
expect(save).not.toHaveBeenCalled();
});
it("takes performance mode from this device, never from the account", () => {
localStorage.setItem(LS.perfMode, "1"); // this device has it on
mount({ performanceMode: false }); // an older build saved the account's value
expect(seen[0]?.perf).toBe(true);
cleanup();
localStorage.setItem(LS.perfMode, "0");
mount({ performanceMode: true });
expect(seen[seen.length - 1]?.perf).toBe(false);
});
it("keeps a performance mode change on this device: nothing is sent to the server", () => {
const save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
mount({});
act(() => controls[controls.length - 1]?.setPerf(true));
act(() => {
vi.advanceTimersByTime(1000);
});
expect(localStorage.getItem(LS.perfMode)).toBe("1");
expect(document.documentElement.dataset.perf).toBe("1");
expect(save).not.toHaveBeenCalled();
});
});
+6 -10
View File
@@ -25,13 +25,13 @@ import { PrefsContext } from "./prefsContext";
const PERF_KEY = LS.perfMode;
// The four Settings-page preferences. They apply locally for instant preview and auto-save (debounced)
// to the server — there is no draft or Save button. This provider owns them (lifted out of the App god
// The Settings page's account preferences. They apply locally for instant preview and auto-save
// (debounced) to the server — there is no draft or Save button. Performance mode is held here too but
// is this device's (a weak GPU wants it, a strong one does not), so it stays in this browser only. This provider owns them (lifted out of the App god
// component) plus the last-saved "baseline" (the server values it was seeded with, then each successful
// save) used to suppress saving what the server already holds. Consumers (SettingsPanel) read the PrefsController via usePrefs() (prefsContext.ts).
type EditablePrefs = {
theme: ThemePrefs;
performanceMode: boolean;
hints: boolean;
railLabels: boolean;
notifications: NotifSettings;
@@ -46,10 +46,6 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
const prefs = qc.getQueryData<Me | null>(qk.me())?.preferences;
return {
theme: prefs?.theme ? normalizeTheme(prefs.theme) : loadLocalTheme(),
performanceMode:
typeof prefs?.performanceMode === "boolean"
? prefs.performanceMode
: getAccountRaw(PERF_KEY) === "1",
hints: typeof prefs?.hints === "boolean" ? prefs.hints : hintsEnabled(),
// B7: off by default — the icon rail with name tooltips is the house look.
railLabels: prefs?.railLabels === true,
@@ -59,7 +55,7 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
};
});
const [theme, setThemeState] = useState<ThemePrefs>(seed.theme);
const [perf, setPerf] = useState(seed.performanceMode);
const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1");
const [hints, setHints] = useState(seed.hints);
const [railLabels, setRailLabels] = useState(seed.railLabels);
const [notif, setNotif] = useState<NotifSettings>(seed.notifications);
@@ -92,8 +88,8 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
// effects above; this is the only persistence path. `baselineRef` keeps the mount seed from being
// saved straight back (`/me` merges, so a full payload is fine).
const editablePrefs = useMemo<EditablePrefs>(
() => ({ theme, performanceMode: perf, hints, railLabels, notifications: notif }),
[theme, perf, hints, railLabels, notif],
() => ({ theme, hints, railLabels, notifications: notif }),
[theme, hints, railLabels, notif],
);
useEffect(() => {
window.clearTimeout(saveTimer.current);
+7 -8
View File
@@ -24,7 +24,7 @@ import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, HttpError, type Me } from "../lib/api";
import { displayName, roleKey } from "../lib/api/account";
import { notifyActionError } from "../lib/actionErrors";
import { LS, useAccountPersistedState, useTabPersistedState } from "../lib/storage";
import { LS, useTabPersistedState } from "../lib/storage";
import Avatar from "./ui/Avatar";
import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./ui/Tooltip";
@@ -35,7 +35,8 @@ import { type SaveState } from "./ui/DraftSaveBar";
import { useConfirm } from "./confirmContext";
import { StateMessage } from "./ui/QueryState";
import { useWizard } from "./wizardContext";
import { START_ON_HOME } from "./navigationInit";
import { START_ON_HOME_OPTIONS, START_ON_HOME_PREF } from "./navigationInit";
import { usePref } from "../lib/usePref";
import { usePrefs } from "./prefsContext";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
@@ -164,10 +165,8 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
const { t } = useTranslation();
// Controlled by PrefsProvider: each change applies locally at once and auto-saves (debounced).
const { theme, setTheme, perf, setPerf, hints, setHints, railLabels, setRailLabels } = prefs;
// The landing page. Per-account localStorage, not a server preference like the rest of this
// tab: `loadInitialPage` reads it synchronously in NavigationProvider's initializer. The two stored
// values come from navigationInit, which is what reads them back at boot.
const [startOnHome, setStartOnHome] = useAccountPersistedState(LS.startOnHome, START_ON_HOME.on);
// The landing page: read at boot (NavigationProvider), so a change applies from the next start.
const [startOnHome, setStartOnHome] = usePref(START_ON_HOME_PREF, true, START_ON_HOME_OPTIONS);
return (
<>
@@ -242,8 +241,8 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
<SwitchRow
label={t("settings.appearance.startOnHome")}
hint={t("settings.appearance.startOnHomeHint")}
checked={startOnHome !== START_ON_HOME.off}
onChange={(v) => setStartOnHome(v ? START_ON_HOME.on : START_ON_HOME.off)}
checked={startOnHome}
onChange={setStartOnHome}
/>
</Section>
@@ -0,0 +1,71 @@
// @vitest-environment jsdom
//
// The "updated" banner shows once per release for the ACCOUNT: the version it was last dismissed for
// is an account preference, so a banner dismissed on one device does not come back on the next. A
// version this browser kept before the server held it (a bare string) still counts.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("../lib/version", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/version")>()),
FRONTEND_VERSION: "1.2.3",
}));
import VersionBanner from "./VersionBanner";
import { api } from "../lib/api";
import { claimLocalData, LS } from "../lib/storage";
import { qk } from "../lib/queryKeys";
let save: ReturnType<typeof vi.spyOn>;
function mount(preferences: Record<string, unknown>) {
const qc = new QueryClient();
qc.setQueryData(qk.me(), { id: 5, preferences });
render(
<QueryClientProvider client={qc}>
<VersionBanner onOpen={() => {}} />
</QueryClientProvider>,
);
}
const banner = () => screen.queryByText("header.banner.updated", { exact: false });
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
beforeEach(() => {
localStorage.clear();
claimLocalData(5);
save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("the release banner", () => {
it("shows for a release the account has not seen", () => {
mount({ seenVersion: "1.2.2" });
expect(banner()).not.toBeNull();
});
it("stays hidden where the account dismissed this release, on another device", () => {
mount({ seenVersion: "1.2.3" });
expect(banner()).toBeNull();
});
it("takes the version this browser kept, and saves it to the account", () => {
localStorage.setItem(LS.seenVersion, "1.2.3");
mount({});
expect(banner()).toBeNull();
expect(save).toHaveBeenCalledWith({ seenVersion: "1.2.3" });
});
it("saves a dismissal to the account", () => {
mount({});
act(() => screen.getByTitle("header.banner.dismiss").click());
expect(banner()).toBeNull();
expect(save).toHaveBeenCalledWith({ seenVersion: "1.2.3" });
});
});
+5 -9
View File
@@ -1,23 +1,19 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Sparkles } from "lucide-react";
import { FRONTEND_VERSION, SEEN_VERSION_KEY } from "../lib/version";
import { FRONTEND_VERSION, SEEN_VERSION_OPTIONS, SEEN_VERSION_PREF } from "../lib/version";
import { usePref } from "../lib/usePref";
import Banner from "./ui/Banner";
// Eye-catching, dismissible bar shown once after the running build's version changes
// (compares the baked frontend version to the last one the user acknowledged).
export default function VersionBanner({ onOpen }: { onOpen: () => void }) {
const { t } = useTranslation();
const [dismissed, setDismissed] = useState(false);
const seen = localStorage.getItem(SEEN_VERSION_KEY);
const [seen, setSeen] = usePref(SEEN_VERSION_PREF, null, SEEN_VERSION_OPTIONS);
// Don't nag in un-stamped dev builds, or once this version has been seen.
const show = FRONTEND_VERSION !== "dev" && seen !== FRONTEND_VERSION && !dismissed;
const show = FRONTEND_VERSION !== "dev" && seen !== FRONTEND_VERSION;
if (!show) return null;
function markSeen() {
localStorage.setItem(SEEN_VERSION_KEY, FRONTEND_VERSION);
setDismissed(true);
}
const markSeen = () => setSeen(FRONTEND_VERSION);
return (
<Banner
@@ -0,0 +1,81 @@
// @vitest-environment jsdom
//
// Whether the onboarding wizard opens by itself is the account's preference: dismissed on one device,
// it stays shut on the others. A dismissal this browser kept before the server held it (the string
// "1") still counts, and reaches the server.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("./OnboardingWizard", () => ({
default: ({ onClose }: { onClose: (dismiss: boolean) => void }) => (
<button onClick={() => onClose(true)}>skip the wizard</button>
),
}));
import { WizardProvider } from "./WizardProvider";
import { api } from "../lib/api";
import { claimLocalData, LS } from "../lib/storage";
import { qk } from "../lib/queryKeys";
let save: ReturnType<typeof vi.spyOn>;
function mount(preferences: Record<string, unknown>) {
const qc = new QueryClient();
qc.setQueryData(qk.me(), {
id: 5,
can_read: false,
can_write: false,
google_enabled: true,
preferences,
});
render(
<QueryClientProvider client={qc}>
<WizardProvider>{null}</WizardProvider>
</QueryClientProvider>,
);
return qc;
}
const wizard = () => screen.queryByRole("button", { name: "skip the wizard" });
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
claimLocalData(5);
save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("the onboarding wizard's auto-open", () => {
it("opens for an account that has not connected YouTube nor dismissed it", async () => {
mount({});
expect(await screen.findByRole("button", { name: "skip the wizard" })).toBeTruthy();
});
it("stays shut where the account dismissed it, on another device", async () => {
mount({ onboardingDismissed: true });
await act(async () => {});
expect(wizard()).toBeNull();
});
it("takes this browser's earlier dismissal and saves it to the account", async () => {
localStorage.setItem(LS.onboardingDismissed, "1");
mount({});
await act(async () => {});
expect(wizard()).toBeNull();
expect(save).toHaveBeenCalledWith({ onboardingDismissed: true });
});
it("saves a dismissal to the account, and stays shut", async () => {
const qc = mount({});
const skip = await screen.findByRole("button", { name: "skip the wizard" });
await act(async () => skip.click());
expect(save).toHaveBeenCalledWith({ onboardingDismissed: true });
expect(
qc.getQueryData<{ preferences: Record<string, unknown> }>(qk.me())?.preferences,
).toMatchObject({ onboardingDismissed: true });
expect(wizard()).toBeNull();
});
});
+21 -4
View File
@@ -2,7 +2,12 @@ import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type ReactNo
import { qk } from "../lib/queryKeys";
import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api";
import { shouldAutoOpenOnboarding } from "../lib/onboarding";
import {
ONBOARDING_DISMISSED_OPTIONS,
ONBOARDING_DISMISSED_PREF,
shouldAutoOpenOnboarding,
} from "../lib/onboarding";
import { prefValue, usePref } from "../lib/usePref";
import { WizardActionsContext, type WizardActions } from "./wizardContext";
const OnboardingWizard = lazy(() => import("./OnboardingWizard"));
@@ -23,8 +28,20 @@ export function WizardProvider({ children }: { children: ReactNode }) {
// Passive observer of the account BootGate fetches (enabled:false → reads cache, re-renders on update).
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
// The setter only: the check below reads the value from `me`, which the setter keeps current.
const [, setDismissed] = usePref(ONBOARDING_DISMISSED_PREF, false, ONBOARDING_DISMISSED_OPTIONS);
const dismissed = prefValue(me, ONBOARDING_DISMISSED_PREF, false, ONBOARDING_DISMISSED_OPTIONS);
const openWizard = useCallback(() => setOpen(true), []);
const closeWizard = useCallback(() => setOpen(false), []);
// Leaving without the essential read step suppresses the auto-popup from then on.
const finishWizard = useCallback(
(dismiss: boolean) => {
if (dismiss) setDismissed(true);
setOpen(false);
},
[setDismissed],
);
// First-login onboarding: prompt the user to connect YouTube (and resume the flow after each
// consent redirect). Re-checked when the account or its grants change.
@@ -39,8 +56,8 @@ export function WizardProvider({ children }: { children: ReactNode }) {
if (meId == null || canRead == null) return;
const account = { can_read: canRead, google_enabled: googleEnabled };
// eslint-disable-next-line react-hooks/set-state-in-effect -- opening is a one-way decision taken from SERVER data as it arrives, not a value derivable in render — deriving `open` from the grants would re-open a wizard the user had just closed, which is exactly what the narrowed dep list above exists to prevent.
if (shouldAutoOpenOnboarding(account)) setOpen(true);
}, [meId, canRead, canWrite, googleEnabled]);
if (shouldAutoOpenOnboarding(account, dismissed)) setOpen(true);
}, [meId, canRead, canWrite, googleEnabled, dismissed]);
const actions = useMemo<WizardActions>(
() => ({ openWizard, closeWizard }),
@@ -52,7 +69,7 @@ export function WizardProvider({ children }: { children: ReactNode }) {
{children}
{/* The wizard is a full-screen overlay. Lazy — its chunk loads only when first opened. */}
<Suspense fallback={null}>
{open && me && <OnboardingWizard me={me} onClose={closeWizard} />}
{open && me && <OnboardingWizard me={me} onClose={finishWizard} />}
</Suspense>
</WizardActionsContext.Provider>
);
+25 -21
View File
@@ -6,7 +6,8 @@
// • channelViewFrom — the shared _chan/?chan resolver behind BOTH the channelView seed and the
// onPop handler: a popstate landing on a URL-only bookmark entry must restore the
// channel from ?chan, not close it.
// Only getAccountRaw is stubbed (the account's stored landing page); everything else is real.
// Only getAccountRaw is stubbed (the page the account left open); everything else is real. Whether
// to start on Home is the account's preference, handed in.
import { describe, it, expect, beforeEach, vi } from "vitest";
const { stored } = vi.hoisted(() => ({ stored: {} as Record<string, string | null> }));
@@ -15,87 +16,90 @@ vi.mock("../lib/storage", async (importOriginal) => {
return { ...actual, getAccountRaw: (k: string) => stored[k] ?? null };
});
import { loadInitialPage, channelViewFrom, START_ON_HOME } from "./navigationInit";
import { loadInitialPage, channelViewFrom } from "./navigationInit";
import { LS } from "../lib/storage";
const setUrl = (search: string) => window.history.replaceState(null, "", search || "/");
let startOnHome = true;
beforeEach(() => {
setUrl("/");
startOnHome = true;
for (const k of Object.keys(stored)) delete stored[k];
});
describe("loadInitialPage precedence", () => {
it("?page wins and is validated", () => {
setUrl("?page=stats");
expect(loadInitialPage()).toBe("stats");
expect(loadInitialPage(startOnHome)).toBe("stats");
});
it("an invalid ?page falls back to feed", () => {
setUrl("?page=bogus");
expect(loadInitialPage()).toBe("feed");
expect(loadInitialPage(startOnHome)).toBe("feed");
});
it("?v implies the plex module", () => {
setUrl("?v=show:1");
expect(loadInitialPage()).toBe("plex");
expect(loadInitialPage(startOnHome)).toBe("plex");
});
it("?pl implies playlists", () => {
setUrl("?pl=3");
expect(loadInitialPage()).toBe("playlists");
expect(loadInitialPage(startOnHome)).toBe("playlists");
});
it.each(["?folder=4", "?item=12", "?folder=all&item=12"])(
"%s implies the Files page (a folder, or a file to arrive at)",
(search) => {
setUrl(search);
expect(loadInitialPage()).toBe("files");
expect(loadInitialPage(startOnHome)).toBe("files");
},
);
it("a filter param implies the feed", () => {
setUrl("?tags=1,2");
expect(loadInitialPage()).toBe("feed");
expect(loadInitialPage(startOnHome)).toBe("feed");
});
it("?chan (the channel overlay) implies the feed", () => {
setUrl("?chan=UC1");
expect(loadInitialPage()).toBe("feed");
expect(loadInitialPage(startOnHome)).toBe("feed");
});
it("?page beats a lower-precedence param", () => {
setUrl("?page=stats&v=show:1");
expect(loadInitialPage()).toBe("stats");
expect(loadInitialPage(startOnHome)).toBe("stats");
});
it("?v beats ?pl and filters", () => {
setUrl("?v=show:1&pl=3&tags=1");
expect(loadInitialPage()).toBe("plex");
expect(loadInitialPage(startOnHome)).toBe("plex");
});
// A bare URL lands on Home, OVER the remembered module. Landing on
// whatever you happened to close the app on is precisely the habit the anti-paralysis surface
// exists to replace, so the remembered page only wins once the account opts out.
it("a bare URL lands on Home even when another module is remembered", () => {
stored[LS.page] = "plex";
expect(loadInitialPage()).toBe("home");
expect(loadInitialPage(startOnHome)).toBe("home");
});
it("opting out restores the account's stored landing page", () => {
stored[LS.startOnHome] = START_ON_HOME.off;
startOnHome = false;
stored[LS.page] = "plex";
expect(loadInitialPage()).toBe("plex");
expect(loadInitialPage(startOnHome)).toBe("plex");
});
// The Downloads page is gone; what it held is in Files. A bookmark and a remembered page both land
// there instead of on the feed.
it("a retired page lands where its content went — linked or remembered", () => {
setUrl("?page=downloads");
expect(loadInitialPage()).toBe("files");
expect(loadInitialPage(startOnHome)).toBe("files");
setUrl("/");
stored[LS.startOnHome] = START_ON_HOME.off;
startOnHome = false;
stored[LS.page] = "downloads";
expect(loadInitialPage()).toBe("files");
expect(loadInitialPage(startOnHome)).toBe("files");
});
it("opted out with no / invalid stored page still defaults to feed", () => {
stored[LS.startOnHome] = START_ON_HOME.off;
expect(loadInitialPage()).toBe("feed");
startOnHome = false;
expect(loadInitialPage(startOnHome)).toBe("feed");
stored[LS.page] = "nope";
expect(loadInitialPage()).toBe("feed");
expect(loadInitialPage(startOnHome)).toBe("feed");
});
it("a deep link still beats Home", () => {
setUrl("?pl=3");
expect(loadInitialPage()).toBe("playlists");
expect(loadInitialPage(startOnHome)).toBe("playlists");
});
});
+12 -11
View File
@@ -4,24 +4,25 @@
// channelView seed and the onPop handler.
import { hasFilterParams, readPage, resolvePage, type Page } from "../lib/urlState";
import { getAccountRaw, LS } from "../lib/storage";
import type { UsePrefOptions } from "../lib/usePref";
export const PAGE_KEY = LS.page;
// Module-local: the Settings toggle writes `LS.startOnHome` directly, so exporting this alias would
// only be a second name for the same key.
const START_HOME_KEY = LS.startOnHome;
/** The two stored values of the landing preference (raw strings, via useAccountPersistedState).
* Settings → Appearance → Startup writes them and `loadInitialPage` reads them, so they are defined
* ONCE: the same magic string spelled out in a writer and a reader is precisely the drift that has
* bitten this codebase before (`cancelled` vs `canceled`). */
export const START_ON_HOME = { on: "1", off: "0" } as const;
/** The landing preference — start on Home, or on the module left open — an account preference
* (usePref). Settings → Appearance → Startup writes it and NavigationProvider reads it for
* `loadInitialPage`, so its key and its reading are defined ONCE. A copy this browser kept before
* the server held it is the string "0" (off) or "1". */
export const START_ON_HOME_PREF = "startOnHome";
export const START_ON_HOME_OPTIONS: UsePrefOptions<boolean> = {
localKey: LS.startOnHome,
parse: (raw) => raw !== false && raw !== "0" && raw !== 0,
};
export interface ChannelView {
id: string;
name?: string;
}
export function loadInitialPage(): Page {
export function loadInitialPage(startOnHome: boolean): Page {
const params = new URLSearchParams(window.location.search);
if (params.has("page")) return readPage();
// A view-specific param implies its page, so a bookmarked deep link lands on the right module even
@@ -35,7 +36,7 @@ export function loadInitialPage(): Page {
// on whatever you happened to close the app on is the behaviour the anti-paralysis epic exists to
// replace. The account can opt out (Settings → Appearance → Startup) and get the remembered
// module back.
if (getAccountRaw(START_HOME_KEY) !== START_ON_HOME.off) return "home";
if (startOnHome) return "home";
return resolvePage(getAccountRaw(PAGE_KEY)) ?? "feed";
}