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:
@@ -0,0 +1,55 @@
|
|||||||
|
import { test, expect, devices, type Browser, type Page } from "@playwright/test";
|
||||||
|
import { MEMBER_STORAGE, gotoPage, openApp } from "./helpers";
|
||||||
|
|
||||||
|
// One member on two devices, a desktop and an iPad, each with its own browser storage. A preference is
|
||||||
|
// a choice about the account, so a change on one device shows on the other; a device setting (here
|
||||||
|
// performance mode, for a weak GPU) stays on the device it was set on. Kept in browser storage only, a
|
||||||
|
// preference used to stay on the one device it was changed on.
|
||||||
|
|
||||||
|
async function device(browser: Browser, descriptor: (typeof devices)[string]): Promise<Page> {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
...descriptor,
|
||||||
|
baseURL: test.info().project.use.baseURL,
|
||||||
|
locale: "en-US",
|
||||||
|
storageState: MEMBER_STORAGE,
|
||||||
|
});
|
||||||
|
return context.newPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedPrefs = (page: Page) =>
|
||||||
|
page.waitForResponse(
|
||||||
|
(r) => r.url().includes("/me/preferences") && r.request().method() === "PUT",
|
||||||
|
);
|
||||||
|
|
||||||
|
async function pickLayout(page: Page, from: string, to: string): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: `Layout: ${from}` }).click();
|
||||||
|
await Promise.all([savedPrefs(page), page.getByRole("menuitemradio", { name: to }).click()]);
|
||||||
|
await expect(page.getByRole("button", { name: `Layout: ${to}` })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a preference follows the member to another device, a device setting stays", async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const desktop = await device(browser, devices["Desktop Chrome"]);
|
||||||
|
const ipad = await device(browser, devices["iPad (gen 7) landscape"]);
|
||||||
|
try {
|
||||||
|
await openApp(desktop);
|
||||||
|
await gotoPage(desktop, "channels");
|
||||||
|
await pickLayout(desktop, "Table", "Cards");
|
||||||
|
await gotoPage(desktop, "settings");
|
||||||
|
await desktop.getByRole("switch", { name: "Performance mode" }).click();
|
||||||
|
await expect(desktop.locator("html")).toHaveAttribute("data-perf", "1");
|
||||||
|
|
||||||
|
await openApp(ipad);
|
||||||
|
await expect(ipad.locator("html")).not.toHaveAttribute("data-perf", "1");
|
||||||
|
await gotoPage(ipad, "channels");
|
||||||
|
await expect(ipad.getByRole("button", { name: "Layout: Cards" })).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
// The member is shared by the suite: put the layout back (performance mode went with the
|
||||||
|
// desktop's storage).
|
||||||
|
await gotoPage(desktop, "channels");
|
||||||
|
await pickLayout(desktop, "Cards", "Table");
|
||||||
|
await desktop.context().close();
|
||||||
|
await ipad.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -5,7 +5,9 @@ import { openApp, gotoPage } from "./helpers";
|
|||||||
// Appearance change persists on its own via PUT /me/preferences and a transient indicator confirms
|
// Appearance change persists on its own via PUT /me/preferences and a transient indicator confirms
|
||||||
// it; leaving Settings after a change is no longer guarded.
|
// it; leaving Settings after a change is no longer guarded.
|
||||||
test.describe("settings auto-save", () => {
|
test.describe("settings auto-save", () => {
|
||||||
const TOGGLE = "Performance mode"; // an Appearance switch (locale pinned to en)
|
// An Appearance switch saved to the account (locale pinned to en). Not performance mode: that one
|
||||||
|
// is the device's and is never sent.
|
||||||
|
const TOGGLE = "Show hints";
|
||||||
|
|
||||||
// The save is debounced (~500ms), so wait for the PUT rather than assuming it's synchronous.
|
// The save is debounced (~500ms), so wait for the PUT rather than assuming it's synchronous.
|
||||||
const putPrefs = (page: Page) =>
|
const putPrefs = (page: Page) =>
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ import { defineConfig, devices } from "@playwright/test";
|
|||||||
const BASE_URL = process.env.PW_BASE_URL || "http://127.0.0.1:8080";
|
const BASE_URL = process.env.PW_BASE_URL || "http://127.0.0.1:8080";
|
||||||
const STORAGE_STATE = "e2e/.auth/state.json";
|
const STORAGE_STATE = "e2e/.auth/state.json";
|
||||||
// The axe scan reads the DOM and the accessibility tree, which the engines build alike, and the
|
// The axe scan reads the DOM and the accessibility tree, which the engines build alike, and the
|
||||||
// member spec checks which pages and requests a role gets, which no engine changes: one engine
|
// member spec checks which pages and requests a role gets, which no engine changes, and the devices
|
||||||
// answers for all, so the other projects leave them out instead of tripling their time.
|
// spec opens its own desktop and iPad contexts: one engine answers for all, so the other projects
|
||||||
const CHROMIUM_ONLY = /(a11y|member)\.spec\.ts/;
|
// leave them out instead of tripling their time.
|
||||||
|
const CHROMIUM_ONLY = /(a11y|member|devices)\.spec\.ts/;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { UserPlus } from "lucide-react";
|
|||||||
import { api, type DiscoveredChannel } from "../lib/api";
|
import { api, type DiscoveredChannel } from "../lib/api";
|
||||||
import { LS, ownKey } from "../lib/storage";
|
import { LS, ownKey } from "../lib/storage";
|
||||||
import { formatCountOrDash } from "../lib/format";
|
import { formatCountOrDash } from "../lib/format";
|
||||||
import { sortRows } from "../lib/columnSort";
|
import { hydrateSort, sortRows, type SortState } from "../lib/columnSort";
|
||||||
import { useSortState } from "../lib/useSortState";
|
import { usePref } from "../lib/usePref";
|
||||||
import { useCardPager } from "../lib/useCardPager";
|
import { useCardPager } from "../lib/useCardPager";
|
||||||
import { subsColumn } from "./channelColumns";
|
import { subsColumn } from "./channelColumns";
|
||||||
import { notify } from "../lib/notifications";
|
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
|
// 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.
|
// this tab's own (its columns differ), lifted out of the table so the card layout shares it.
|
||||||
const layout = useChannelLayout();
|
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({
|
const query = useQuery({
|
||||||
queryKey: qk.discoveredChannels(),
|
queryKey: qk.discoveredChannels(),
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
//
|
//
|
||||||
// Uses REAL storage (jsdom localStorage/sessionStorage) with account 1's local state claimed, so
|
// Uses REAL storage (jsdom localStorage/sessionStorage) with account 1's local state claimed, so
|
||||||
// useAccountPersistedState / useSortState / useFilterState all resolve their account-scoped keys; only
|
// 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 { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { renderHook, act, render as renderRoot } from "@testing-library/react";
|
import { renderHook, act, render as renderRoot } from "@testing-library/react";
|
||||||
import { useEffect, useEffectEvent, type ReactNode } from "react";
|
import { useEffect, useEffectEvent, type ReactNode } from "react";
|
||||||
@@ -17,11 +18,19 @@ vi.mock("./navigationContext", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { ChannelsProvider } from "./ChannelsProvider";
|
import { ChannelsProvider } from "./ChannelsProvider";
|
||||||
import { useChannels, useChannelsActions } from "./channelsContext";
|
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
|
||||||
import { claimLocalData } from "../lib/storage";
|
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 }) => (
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
<ChannelsProvider>{children}</ChannelsProvider>
|
<ChannelsProvider>{children}</ChannelsProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
const withClient = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||||
);
|
);
|
||||||
const render = () => renderHook(() => ({ ...useChannels(), ...useChannelsActions() }), { wrapper });
|
const render = () => renderHook(() => ({ ...useChannels(), ...useChannelsActions() }), { wrapper });
|
||||||
const VIEW_KEY = "siftlode.channelsView"; // the account's (claimed in beforeEach)
|
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(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
claimLocalData(1); // the browser holds account 1's state
|
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,
|
// 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
|
// 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.
|
// this provider's mount-time URL seed and change the starting tab.
|
||||||
@@ -166,6 +177,7 @@ describe("ChannelsProvider — the focus-a-channel intent", () => {
|
|||||||
<Probe />
|
<Probe />
|
||||||
<Harness mounted />
|
<Harness mounted />
|
||||||
</ChannelsProvider>,
|
</ChannelsProvider>,
|
||||||
|
{ wrapper: withClient },
|
||||||
);
|
);
|
||||||
|
|
||||||
act(() => api.focusChannel("Kurzgesagt"));
|
act(() => api.focusChannel("Kurzgesagt"));
|
||||||
@@ -202,6 +214,7 @@ describe("ChannelsProvider — the focus-a-channel intent", () => {
|
|||||||
<Probe />
|
<Probe />
|
||||||
<Harness mounted />
|
<Harness mounted />
|
||||||
</ChannelsProvider>,
|
</ChannelsProvider>,
|
||||||
|
{ wrapper: withClient },
|
||||||
);
|
);
|
||||||
|
|
||||||
act(() => api.focusChannel("Kurzgesagt"));
|
act(() => api.focusChannel("Kurzgesagt"));
|
||||||
@@ -210,3 +223,14 @@ describe("ChannelsProvider — the focus-a-channel intent", () => {
|
|||||||
expect(api.search).toBe("Kurzgesagt");
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { parseChannelLayout } from "../lib/channelLayout";
|
|||||||
import { hydrateSort } from "../lib/columnSort";
|
import { hydrateSort } from "../lib/columnSort";
|
||||||
import { useLatest } from "../lib/useLatest";
|
import { useLatest } from "../lib/useLatest";
|
||||||
import { useSortState } from "../lib/useSortState";
|
import { useSortState } from "../lib/useSortState";
|
||||||
|
import { usePref } from "../lib/usePref";
|
||||||
import { useFilterState } from "../lib/useFilterState";
|
import { useFilterState } from "../lib/useFilterState";
|
||||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||||
import { rememberNavPosition } from "../lib/history";
|
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
|
// 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.
|
// chips, the Sync column funnel, and the card layout share one state; useFilterState persists it.
|
||||||
const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip);
|
const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip);
|
||||||
// view + layout are persisted tabs (useAccountPersistedState = the shared account-scoped tab-state hook).
|
// The view is this browser's (useAccountPersistedState), the layout an account preference
|
||||||
// Stored un-clamped; clampView/parseChannelLayout heal them for display. setView layers the history
|
// (usePref). Stored un-clamped; clampView/parseChannelLayout heal them for display. setView layers
|
||||||
// logic (below) on top of the raw setter.
|
// the history logic (below) on top of the raw setter.
|
||||||
const [viewRaw, setViewRaw] = useAccountPersistedState(LS.channelsView, "subscribed");
|
const [viewRaw, setViewRaw] = useAccountPersistedState(LS.channelsView, "subscribed");
|
||||||
const view = clampView(viewRaw);
|
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);
|
const layout = parseChannelLayout(layoutRaw);
|
||||||
// Sort is owned here (lifted out of DataTable) so the card layout shares it; ColumnSortControl heals
|
// 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).
|
// 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 { qk } from "../lib/queryKeys";
|
||||||
import { claimLocalData, LS, setAccountRaw } from "../lib/storage";
|
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
|
/** 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. */
|
* 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", () => {
|
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");
|
setAccountRaw(PAGE_KEY, "logs");
|
||||||
window.history.replaceState(null, "", "/");
|
window.history.replaceState(null, "", "/");
|
||||||
const { result } = render();
|
const { result } = render();
|
||||||
@@ -271,6 +277,19 @@ describe("NavigationProvider · Back", () => {
|
|||||||
expect(new URLSearchParams(window.location.search).get("at")).toBe("logs");
|
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", () => {
|
it("opens a retired page's tab when Back lands on its entry", () => {
|
||||||
const { result } = render();
|
const { result } = render();
|
||||||
act(() => result.current.setPage("plex"));
|
act(() => result.current.setPage("plex"));
|
||||||
|
|||||||
@@ -15,7 +15,15 @@ import {
|
|||||||
rememberNavPosition,
|
rememberNavPosition,
|
||||||
} from "../lib/history";
|
} from "../lib/history";
|
||||||
import { reachablePage } from "../lib/modules";
|
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 {
|
import {
|
||||||
NavigationActionsContext,
|
NavigationActionsContext,
|
||||||
NavigationStateContext,
|
NavigationStateContext,
|
||||||
@@ -32,7 +40,10 @@ import {
|
|||||||
|
|
||||||
export function NavigationProvider({ children }: { children: ReactNode }) {
|
export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||||
const { t, i18n } = useTranslation();
|
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
|
// 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
|
// 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.
|
// and the address bar (stamped from it below) agree.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("the onboarding wizard's Escape", () => {
|
describe("the onboarding wizard's Escape", () => {
|
||||||
const mount = (onClose: () => void, dialog: boolean) =>
|
const mount = (onClose: (dismiss: boolean) => void, dialog: boolean) =>
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider
|
<QueryClientProvider
|
||||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||||
@@ -51,7 +51,8 @@ describe("the onboarding wizard's Escape", () => {
|
|||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
mount(onClose, false);
|
mount(onClose, false);
|
||||||
fireEvent.keyDown(document, { key: "Escape" });
|
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", () => {
|
it("leaves the wizard alone while a dialog is open over it", () => {
|
||||||
|
|||||||
@@ -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 { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const importTriggered = useRef(false);
|
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
|
// Closing without finishing the essential read step suppresses future auto-popups
|
||||||
// (it stays reachable from Settings). Once read is granted, closing is just "done".
|
// (it stays reachable from Settings). Once read is granted, closing is just "done".
|
||||||
const close = () => {
|
const close = () => {
|
||||||
endOnboarding(!me.can_read);
|
endOnboarding();
|
||||||
onClose();
|
onClose(!me.can_read);
|
||||||
};
|
};
|
||||||
const overlayOrder = useOverlayOrder(); // this overlay's toast-host rank (see ui/Toaster)
|
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"
|
variant="ghost"
|
||||||
size="md"
|
size="md"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
endOnboarding(false);
|
endOnboarding();
|
||||||
onClose();
|
onClose(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("onboarding.write.keepReadOnly")}
|
{t("onboarding.write.keepReadOnly")}
|
||||||
@@ -299,8 +306,8 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
|||||||
size="lg"
|
size="lg"
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
endOnboarding(false);
|
endOnboarding();
|
||||||
onClose();
|
onClose(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("onboarding.done.done")}
|
{t("onboarding.done.done")}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { compareText } from "../lib/collate";
|
|||||||
import { formatRuntime } from "../lib/format";
|
import { formatRuntime } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
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 { type PanelLayout } from "../lib/panelLayout";
|
||||||
import FilterRail from "./FilterRail";
|
import FilterRail from "./FilterRail";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
@@ -65,7 +66,10 @@ export default function PlaylistsRail({
|
|||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
const qc = useQueryClient();
|
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[] = [
|
const plSortOptions: SortOption[] = [
|
||||||
{ key: "custom", label: t("playlists.railSortCustom"), directionless: true },
|
{ key: "custom", label: t("playlists.railSortCustom"), directionless: true },
|
||||||
{ key: "name", label: t("playlists.railSortName"), defaultDir: "asc" },
|
{ 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.
|
// default: a rail that reshuffles itself while you look at it costs more than it saves.
|
||||||
{ key: "recent", label: t("playlists.railSortRecent"), defaultDir: "desc" },
|
{ key: "recent", label: t("playlists.railSortRecent"), defaultDir: "desc" },
|
||||||
];
|
];
|
||||||
useEffect(() => {
|
|
||||||
writeAccount(LS.plSort, plSort);
|
|
||||||
}, [plSort]);
|
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
const plActions = usePlaylistActions();
|
const plActions = usePlaylistActions();
|
||||||
|
|||||||
@@ -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 { qk } from "../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -27,7 +27,8 @@ import { notifyActionError } from "../lib/actionErrors";
|
|||||||
import { navItemAttrs, useListNav } from "../lib/listNav";
|
import { navItemAttrs, useListNav } from "../lib/listNav";
|
||||||
import { toggleWatchState } from "../lib/plexWatch";
|
import { toggleWatchState } from "../lib/plexWatch";
|
||||||
import { formatRuntime } from "../lib/format";
|
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 { useConfirm } from "./confirmContext";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||||
@@ -424,12 +425,10 @@ export default function PlexPlaylistView({
|
|||||||
const [renaming, setRenaming] = useState(false);
|
const [renaming, setRenaming] = useState(false);
|
||||||
const { target: renameTarget, arm: armRenameReturn } = useReturnFocus(renaming);
|
const { target: renameTarget, arm: armRenameReturn } = useReturnFocus(renaming);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [layout, setLayout] = useState<Layout>(() =>
|
const [layout, setLayout] = usePref<Layout>("plexPlaylistLayout", "accordion", {
|
||||||
getAccountRaw(LS.plexPlaylistLayout) === "tree" ? "tree" : "accordion",
|
localKey: LS.plexPlaylistLayout,
|
||||||
);
|
parse: (raw) => (raw === "tree" ? "tree" : "accordion"),
|
||||||
useEffect(() => {
|
});
|
||||||
setAccountRaw(LS.plexPlaylistLayout, layout);
|
|
||||||
}, [layout]);
|
|
||||||
// Show groups start collapsed (compact headers) so a long series doesn't force endless scrolling;
|
// 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.
|
// seasons start expanded once their show is opened. Both are just local view state.
|
||||||
const [expandedShows, setExpandedShows] = useState<Set<string>>(new Set());
|
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
|
// 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
|
// 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
|
// gone, a seed that read only this browser's copy would never show a hints setting changed on another
|
||||||
// changed on another device. And the seed is not an edit: it must not be saved straight back.
|
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { act, cleanup, render } from "@testing-library/react";
|
import { act, cleanup, render } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
@@ -14,16 +15,31 @@ import { claimLocalData, LS } from "../lib/storage";
|
|||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
|
|
||||||
const seen: { perf: boolean; hints: boolean }[] = [];
|
const seen: { perf: boolean; hints: boolean }[] = [];
|
||||||
|
const controls: ReturnType<typeof usePrefs>[] = [];
|
||||||
function Probe() {
|
function Probe() {
|
||||||
const { perf, hints } = usePrefs();
|
const prefs = usePrefs();
|
||||||
seen.push({ perf, hints });
|
controls.push(prefs);
|
||||||
|
seen.push({ perf: prefs.perf, hints: prefs.hints });
|
||||||
return null;
|
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(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
claimLocalData(5);
|
claimLocalData(5);
|
||||||
seen.length = 0;
|
seen.length = 0;
|
||||||
|
controls.length = 0;
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -34,24 +50,38 @@ afterEach(() => {
|
|||||||
|
|
||||||
describe("PrefsProvider seed", () => {
|
describe("PrefsProvider seed", () => {
|
||||||
it("renders the account's server prefs from the first render, and does not save them back", () => {
|
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");
|
localStorage.setItem(LS.hints, "1");
|
||||||
const save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
|
const save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
|
||||||
const qc = new QueryClient();
|
mount({ hints: false });
|
||||||
qc.setQueryData(qk.me(), { id: 5, preferences: { performanceMode: true, hints: false } });
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={qc}>
|
|
||||||
<PrefsProvider>
|
|
||||||
<Probe />
|
|
||||||
</PrefsProvider>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(seen[0]).toEqual({ perf: true, hints: false });
|
expect(seen[0]?.hints).toBe(false);
|
||||||
// past the auto-save debounce
|
// past the auto-save debounce
|
||||||
act(() => {
|
act(() => {
|
||||||
vi.advanceTimersByTime(1000);
|
vi.advanceTimersByTime(1000);
|
||||||
});
|
});
|
||||||
expect(save).not.toHaveBeenCalled();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ import { PrefsContext } from "./prefsContext";
|
|||||||
|
|
||||||
const PERF_KEY = LS.perfMode;
|
const PERF_KEY = LS.perfMode;
|
||||||
|
|
||||||
// The four Settings-page preferences. They apply locally for instant preview and auto-save (debounced)
|
// The Settings page's account preferences. They apply locally for instant preview and auto-save
|
||||||
// to the server — there is no draft or Save button. This provider owns them (lifted out of the App god
|
// (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
|
// 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).
|
// save) used to suppress saving what the server already holds. Consumers (SettingsPanel) read the PrefsController via usePrefs() (prefsContext.ts).
|
||||||
type EditablePrefs = {
|
type EditablePrefs = {
|
||||||
theme: ThemePrefs;
|
theme: ThemePrefs;
|
||||||
performanceMode: boolean;
|
|
||||||
hints: boolean;
|
hints: boolean;
|
||||||
railLabels: boolean;
|
railLabels: boolean;
|
||||||
notifications: NotifSettings;
|
notifications: NotifSettings;
|
||||||
@@ -46,10 +46,6 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
|
|||||||
const prefs = qc.getQueryData<Me | null>(qk.me())?.preferences;
|
const prefs = qc.getQueryData<Me | null>(qk.me())?.preferences;
|
||||||
return {
|
return {
|
||||||
theme: prefs?.theme ? normalizeTheme(prefs.theme) : loadLocalTheme(),
|
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(),
|
hints: typeof prefs?.hints === "boolean" ? prefs.hints : hintsEnabled(),
|
||||||
// B7: off by default — the icon rail with name tooltips is the house look.
|
// B7: off by default — the icon rail with name tooltips is the house look.
|
||||||
railLabels: prefs?.railLabels === true,
|
railLabels: prefs?.railLabels === true,
|
||||||
@@ -59,7 +55,7 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
const [theme, setThemeState] = useState<ThemePrefs>(seed.theme);
|
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 [hints, setHints] = useState(seed.hints);
|
||||||
const [railLabels, setRailLabels] = useState(seed.railLabels);
|
const [railLabels, setRailLabels] = useState(seed.railLabels);
|
||||||
const [notif, setNotif] = useState<NotifSettings>(seed.notifications);
|
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
|
// 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).
|
// saved straight back (`/me` merges, so a full payload is fine).
|
||||||
const editablePrefs = useMemo<EditablePrefs>(
|
const editablePrefs = useMemo<EditablePrefs>(
|
||||||
() => ({ theme, performanceMode: perf, hints, railLabels, notifications: notif }),
|
() => ({ theme, hints, railLabels, notifications: notif }),
|
||||||
[theme, perf, hints, railLabels, notif],
|
[theme, hints, railLabels, notif],
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.clearTimeout(saveTimer.current);
|
window.clearTimeout(saveTimer.current);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
|||||||
import { api, HttpError, type Me } from "../lib/api";
|
import { api, HttpError, type Me } from "../lib/api";
|
||||||
import { displayName, roleKey } from "../lib/api/account";
|
import { displayName, roleKey } from "../lib/api/account";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { LS, useAccountPersistedState, useTabPersistedState } from "../lib/storage";
|
import { LS, useTabPersistedState } from "../lib/storage";
|
||||||
import Avatar from "./ui/Avatar";
|
import Avatar from "./ui/Avatar";
|
||||||
import { notify, type NotifSettings } from "../lib/notifications";
|
import { notify, type NotifSettings } from "../lib/notifications";
|
||||||
import Tooltip from "./ui/Tooltip";
|
import Tooltip from "./ui/Tooltip";
|
||||||
@@ -35,7 +35,8 @@ import { type SaveState } from "./ui/DraftSaveBar";
|
|||||||
import { useConfirm } from "./confirmContext";
|
import { useConfirm } from "./confirmContext";
|
||||||
import { StateMessage } from "./ui/QueryState";
|
import { StateMessage } from "./ui/QueryState";
|
||||||
import { useWizard } from "./wizardContext";
|
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 { usePrefs } from "./prefsContext";
|
||||||
import Tabs from "./ui/Tabs";
|
import Tabs from "./ui/Tabs";
|
||||||
import { useTabPanel } from "./ui/tabPanel";
|
import { useTabPanel } from "./ui/tabPanel";
|
||||||
@@ -164,10 +165,8 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Controlled by PrefsProvider: each change applies locally at once and auto-saves (debounced).
|
// Controlled by PrefsProvider: each change applies locally at once and auto-saves (debounced).
|
||||||
const { theme, setTheme, perf, setPerf, hints, setHints, railLabels, setRailLabels } = prefs;
|
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
|
// The landing page: read at boot (NavigationProvider), so a change applies from the next start.
|
||||||
// tab: `loadInitialPage` reads it synchronously in NavigationProvider's initializer. The two stored
|
const [startOnHome, setStartOnHome] = usePref(START_ON_HOME_PREF, true, START_ON_HOME_OPTIONS);
|
||||||
// values come from navigationInit, which is what reads them back at boot.
|
|
||||||
const [startOnHome, setStartOnHome] = useAccountPersistedState(LS.startOnHome, START_ON_HOME.on);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -242,8 +241,8 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
|
|||||||
<SwitchRow
|
<SwitchRow
|
||||||
label={t("settings.appearance.startOnHome")}
|
label={t("settings.appearance.startOnHome")}
|
||||||
hint={t("settings.appearance.startOnHomeHint")}
|
hint={t("settings.appearance.startOnHomeHint")}
|
||||||
checked={startOnHome !== START_ON_HOME.off}
|
checked={startOnHome}
|
||||||
onChange={(v) => setStartOnHome(v ? START_ON_HOME.on : START_ON_HOME.off)}
|
onChange={setStartOnHome}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,23 +1,19 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Sparkles } from "lucide-react";
|
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";
|
import Banner from "./ui/Banner";
|
||||||
|
|
||||||
// Eye-catching, dismissible bar shown once after the running build's version changes
|
// 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).
|
// (compares the baked frontend version to the last one the user acknowledged).
|
||||||
export default function VersionBanner({ onOpen }: { onOpen: () => void }) {
|
export default function VersionBanner({ onOpen }: { onOpen: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [dismissed, setDismissed] = useState(false);
|
const [seen, setSeen] = usePref(SEEN_VERSION_PREF, null, SEEN_VERSION_OPTIONS);
|
||||||
const seen = localStorage.getItem(SEEN_VERSION_KEY);
|
|
||||||
// Don't nag in un-stamped dev builds, or once this version has been seen.
|
// 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;
|
if (!show) return null;
|
||||||
|
|
||||||
function markSeen() {
|
const markSeen = () => setSeen(FRONTEND_VERSION);
|
||||||
localStorage.setItem(SEEN_VERSION_KEY, FRONTEND_VERSION);
|
|
||||||
setDismissed(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Banner
|
<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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,12 @@ import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type ReactNo
|
|||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "../lib/api";
|
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";
|
import { WizardActionsContext, type WizardActions } from "./wizardContext";
|
||||||
|
|
||||||
const OnboardingWizard = lazy(() => import("./OnboardingWizard"));
|
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).
|
// 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 });
|
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 openWizard = useCallback(() => setOpen(true), []);
|
||||||
const closeWizard = useCallback(() => setOpen(false), []);
|
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
|
// 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.
|
// 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;
|
if (meId == null || canRead == null) return;
|
||||||
const account = { can_read: canRead, google_enabled: googleEnabled };
|
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.
|
// 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);
|
if (shouldAutoOpenOnboarding(account, dismissed)) setOpen(true);
|
||||||
}, [meId, canRead, canWrite, googleEnabled]);
|
}, [meId, canRead, canWrite, googleEnabled, dismissed]);
|
||||||
|
|
||||||
const actions = useMemo<WizardActions>(
|
const actions = useMemo<WizardActions>(
|
||||||
() => ({ openWizard, closeWizard }),
|
() => ({ openWizard, closeWizard }),
|
||||||
@@ -52,7 +69,7 @@ export function WizardProvider({ children }: { children: ReactNode }) {
|
|||||||
{children}
|
{children}
|
||||||
{/* The wizard is a full-screen overlay. Lazy — its chunk loads only when first opened. */}
|
{/* The wizard is a full-screen overlay. Lazy — its chunk loads only when first opened. */}
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
{open && me && <OnboardingWizard me={me} onClose={closeWizard} />}
|
{open && me && <OnboardingWizard me={me} onClose={finishWizard} />}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</WizardActionsContext.Provider>
|
</WizardActionsContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
// • channelViewFrom — the shared _chan/?chan resolver behind BOTH the channelView seed and the
|
// • 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
|
// onPop handler: a popstate landing on a URL-only bookmark entry must restore the
|
||||||
// channel from ?chan, not close it.
|
// 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";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
|
||||||
const { stored } = vi.hoisted(() => ({ stored: {} as Record<string, string | null> }));
|
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 };
|
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";
|
import { LS } from "../lib/storage";
|
||||||
|
|
||||||
const setUrl = (search: string) => window.history.replaceState(null, "", search || "/");
|
const setUrl = (search: string) => window.history.replaceState(null, "", search || "/");
|
||||||
|
|
||||||
|
let startOnHome = true;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setUrl("/");
|
setUrl("/");
|
||||||
|
startOnHome = true;
|
||||||
for (const k of Object.keys(stored)) delete stored[k];
|
for (const k of Object.keys(stored)) delete stored[k];
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("loadInitialPage precedence", () => {
|
describe("loadInitialPage precedence", () => {
|
||||||
it("?page wins and is validated", () => {
|
it("?page wins and is validated", () => {
|
||||||
setUrl("?page=stats");
|
setUrl("?page=stats");
|
||||||
expect(loadInitialPage()).toBe("stats");
|
expect(loadInitialPage(startOnHome)).toBe("stats");
|
||||||
});
|
});
|
||||||
it("an invalid ?page falls back to feed", () => {
|
it("an invalid ?page falls back to feed", () => {
|
||||||
setUrl("?page=bogus");
|
setUrl("?page=bogus");
|
||||||
expect(loadInitialPage()).toBe("feed");
|
expect(loadInitialPage(startOnHome)).toBe("feed");
|
||||||
});
|
});
|
||||||
it("?v implies the plex module", () => {
|
it("?v implies the plex module", () => {
|
||||||
setUrl("?v=show:1");
|
setUrl("?v=show:1");
|
||||||
expect(loadInitialPage()).toBe("plex");
|
expect(loadInitialPage(startOnHome)).toBe("plex");
|
||||||
});
|
});
|
||||||
it("?pl implies playlists", () => {
|
it("?pl implies playlists", () => {
|
||||||
setUrl("?pl=3");
|
setUrl("?pl=3");
|
||||||
expect(loadInitialPage()).toBe("playlists");
|
expect(loadInitialPage(startOnHome)).toBe("playlists");
|
||||||
});
|
});
|
||||||
it.each(["?folder=4", "?item=12", "?folder=all&item=12"])(
|
it.each(["?folder=4", "?item=12", "?folder=all&item=12"])(
|
||||||
"%s implies the Files page (a folder, or a file to arrive at)",
|
"%s implies the Files page (a folder, or a file to arrive at)",
|
||||||
(search) => {
|
(search) => {
|
||||||
setUrl(search);
|
setUrl(search);
|
||||||
expect(loadInitialPage()).toBe("files");
|
expect(loadInitialPage(startOnHome)).toBe("files");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
it("a filter param implies the feed", () => {
|
it("a filter param implies the feed", () => {
|
||||||
setUrl("?tags=1,2");
|
setUrl("?tags=1,2");
|
||||||
expect(loadInitialPage()).toBe("feed");
|
expect(loadInitialPage(startOnHome)).toBe("feed");
|
||||||
});
|
});
|
||||||
it("?chan (the channel overlay) implies the feed", () => {
|
it("?chan (the channel overlay) implies the feed", () => {
|
||||||
setUrl("?chan=UC1");
|
setUrl("?chan=UC1");
|
||||||
expect(loadInitialPage()).toBe("feed");
|
expect(loadInitialPage(startOnHome)).toBe("feed");
|
||||||
});
|
});
|
||||||
it("?page beats a lower-precedence param", () => {
|
it("?page beats a lower-precedence param", () => {
|
||||||
setUrl("?page=stats&v=show:1");
|
setUrl("?page=stats&v=show:1");
|
||||||
expect(loadInitialPage()).toBe("stats");
|
expect(loadInitialPage(startOnHome)).toBe("stats");
|
||||||
});
|
});
|
||||||
it("?v beats ?pl and filters", () => {
|
it("?v beats ?pl and filters", () => {
|
||||||
setUrl("?v=show:1&pl=3&tags=1");
|
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
|
// 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
|
// 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.
|
// 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", () => {
|
it("a bare URL lands on Home even when another module is remembered", () => {
|
||||||
stored[LS.page] = "plex";
|
stored[LS.page] = "plex";
|
||||||
expect(loadInitialPage()).toBe("home");
|
expect(loadInitialPage(startOnHome)).toBe("home");
|
||||||
});
|
});
|
||||||
it("opting out restores the account's stored landing page", () => {
|
it("opting out restores the account's stored landing page", () => {
|
||||||
stored[LS.startOnHome] = START_ON_HOME.off;
|
startOnHome = false;
|
||||||
stored[LS.page] = "plex";
|
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
|
// 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.
|
// there instead of on the feed.
|
||||||
it("a retired page lands where its content went — linked or remembered", () => {
|
it("a retired page lands where its content went — linked or remembered", () => {
|
||||||
setUrl("?page=downloads");
|
setUrl("?page=downloads");
|
||||||
expect(loadInitialPage()).toBe("files");
|
expect(loadInitialPage(startOnHome)).toBe("files");
|
||||||
setUrl("/");
|
setUrl("/");
|
||||||
stored[LS.startOnHome] = START_ON_HOME.off;
|
startOnHome = false;
|
||||||
stored[LS.page] = "downloads";
|
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", () => {
|
it("opted out with no / invalid stored page still defaults to feed", () => {
|
||||||
stored[LS.startOnHome] = START_ON_HOME.off;
|
startOnHome = false;
|
||||||
expect(loadInitialPage()).toBe("feed");
|
expect(loadInitialPage(startOnHome)).toBe("feed");
|
||||||
stored[LS.page] = "nope";
|
stored[LS.page] = "nope";
|
||||||
expect(loadInitialPage()).toBe("feed");
|
expect(loadInitialPage(startOnHome)).toBe("feed");
|
||||||
});
|
});
|
||||||
it("a deep link still beats Home", () => {
|
it("a deep link still beats Home", () => {
|
||||||
setUrl("?pl=3");
|
setUrl("?pl=3");
|
||||||
expect(loadInitialPage()).toBe("playlists");
|
expect(loadInitialPage(startOnHome)).toBe("playlists");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,24 +4,25 @@
|
|||||||
// channelView seed and the onPop handler.
|
// channelView seed and the onPop handler.
|
||||||
import { hasFilterParams, readPage, resolvePage, type Page } from "../lib/urlState";
|
import { hasFilterParams, readPage, resolvePage, type Page } from "../lib/urlState";
|
||||||
import { getAccountRaw, LS } from "../lib/storage";
|
import { getAccountRaw, LS } from "../lib/storage";
|
||||||
|
import type { UsePrefOptions } from "../lib/usePref";
|
||||||
|
|
||||||
export const PAGE_KEY = LS.page;
|
export const PAGE_KEY = LS.page;
|
||||||
// Module-local: the Settings toggle writes `LS.startOnHome` directly, so exporting this alias would
|
/** The landing preference — start on Home, or on the module left open — an account preference
|
||||||
// only be a second name for the same key.
|
* (usePref). Settings → Appearance → Startup writes it and NavigationProvider reads it for
|
||||||
const START_HOME_KEY = LS.startOnHome;
|
* `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". */
|
||||||
/** The two stored values of the landing preference (raw strings, via useAccountPersistedState).
|
export const START_ON_HOME_PREF = "startOnHome";
|
||||||
* Settings → Appearance → Startup writes them and `loadInitialPage` reads them, so they are defined
|
export const START_ON_HOME_OPTIONS: UsePrefOptions<boolean> = {
|
||||||
* ONCE: the same magic string spelled out in a writer and a reader is precisely the drift that has
|
localKey: LS.startOnHome,
|
||||||
* bitten this codebase before (`cancelled` vs `canceled`). */
|
parse: (raw) => raw !== false && raw !== "0" && raw !== 0,
|
||||||
export const START_ON_HOME = { on: "1", off: "0" } as const;
|
};
|
||||||
|
|
||||||
export interface ChannelView {
|
export interface ChannelView {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadInitialPage(): Page {
|
export function loadInitialPage(startOnHome: boolean): Page {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (params.has("page")) return readPage();
|
if (params.has("page")) return readPage();
|
||||||
// A view-specific param implies its page, so a bookmarked deep link lands on the right module even
|
// 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
|
// 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
|
// replace. The account can opt out (Settings → Appearance → Startup) and get the remembered
|
||||||
// module back.
|
// module back.
|
||||||
if (getAccountRaw(START_HOME_KEY) !== START_ON_HOME.off) return "home";
|
if (startOnHome) return "home";
|
||||||
return resolvePage(getAccountRaw(PAGE_KEY)) ?? "feed";
|
return resolvePage(getAccountRaw(PAGE_KEY)) ?? "feed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { LS } from "./storage";
|
||||||
import { createStore } from "./store";
|
import { createStore } from "./store";
|
||||||
|
|
||||||
// The debug recorder's app-wide state — what several surfaces open or read, held outside any
|
// The debug recorder's app-wide state — what several surfaces open or read, held outside any
|
||||||
@@ -47,7 +48,7 @@ export const useComposerSession = (): string | null => composing.useValue();
|
|||||||
// Step markers are numbered per session. The count survives a reload (sessionStorage, per tab): a
|
// Step markers are numbered per session. The count survives a reload (sessionStorage, per tab): a
|
||||||
// reproduction is often "reload and it happens again", and step 4 coming back as step 1 would make
|
// reproduction is often "reload and it happens again", and step 4 coming back as step 1 would make
|
||||||
// the list the issue is drafted from contradict itself.
|
// the list the issue is drafted from contradict itself.
|
||||||
const STEP_KEY = "siftlode.debugSteps";
|
const STEP_KEY = LS.debugSteps;
|
||||||
|
|
||||||
function readSteps(): Record<string, number> {
|
function readSteps(): Record<string, number> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
//
|
//
|
||||||
// Dependency-free on purpose (only the build stamp): the request layer, the notification store and
|
// Dependency-free on purpose (only the build stamp): the request layer, the notification store and
|
||||||
// the error boundary all log through here, so it must not import any of them.
|
// the error boundary all log through here, so it must not import any of them.
|
||||||
|
import { LS } from "./storage";
|
||||||
import { FRONTEND_VERSION } from "./version";
|
import { FRONTEND_VERSION } from "./version";
|
||||||
|
|
||||||
export type Level = "TRACE" | "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL";
|
export type Level = "TRACE" | "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL";
|
||||||
@@ -125,7 +126,7 @@ export function newRequestId(): string {
|
|||||||
|
|
||||||
// Per TAB, not per page load: a reload loop is one tab's story, and sessionStorage is exactly that
|
// Per TAB, not per page load: a reload loop is one tab's story, and sessionStorage is exactly that
|
||||||
// scope. Storage that throws (a locked-down browser) just means a fresh id per load.
|
// scope. Storage that throws (a locked-down browser) just means a fresh id per load.
|
||||||
const TAB_KEY = "siftlode.logTab";
|
const TAB_KEY = LS.logTab;
|
||||||
function tabId(): string {
|
function tabId(): string {
|
||||||
try {
|
try {
|
||||||
const known = sessionStorage.getItem(TAB_KEY);
|
const known = sessionStorage.getItem(TAB_KEY);
|
||||||
|
|||||||
@@ -8,27 +8,32 @@
|
|||||||
// ONBOARD_ACTIVE (sessionStorage) — set while the user is mid-flow (e.g. just clicked
|
// ONBOARD_ACTIVE (sessionStorage) — set while the user is mid-flow (e.g. just clicked
|
||||||
// "Connect"); makes the wizard reopen after the
|
// "Connect"); makes the wizard reopen after the
|
||||||
// redirect so they land on the next step.
|
// redirect so they land on the next step.
|
||||||
// ONBOARD_DISMISSED (localStorage) — set when the user skips the essential read step;
|
// ONBOARDING_DISMISSED_PREF (an — set when the user skips the essential read step;
|
||||||
// suppresses the auto-popup on future logins (they
|
// account preference, usePref) suppresses the auto-popup on future logins, on every
|
||||||
// can still reopen it from Settings → Account).
|
// device (they can still reopen it from Settings → Account).
|
||||||
|
|
||||||
import { connectGoogle } from "./googleConnect";
|
import { connectGoogle } from "./googleConnect";
|
||||||
import { getAccountRaw, LS, setAccountRaw } from "./storage";
|
import { LS } from "./storage";
|
||||||
|
import type { UsePrefOptions } from "./usePref";
|
||||||
|
|
||||||
const ONBOARD_ACTIVE = "siftlode.onboarding.active"; // sessionStorage (not in LS, which is localStorage)
|
const ONBOARD_ACTIVE = LS.onboardingActive; // sessionStorage, this tab only
|
||||||
// The account's (see ownKey): a fresh account should still get the onboarding nudge even if a
|
// The account's, so a fresh account still gets the nudge where another one dismissed it. A copy this
|
||||||
// different account in the same browser dismissed it.
|
// browser kept before the server held it is the string "1".
|
||||||
const ONBOARD_DISMISSED = LS.onboardingDismissed;
|
export const ONBOARDING_DISMISSED_PREF = "onboardingDismissed";
|
||||||
|
export const ONBOARDING_DISMISSED_OPTIONS: UsePrefOptions<boolean> = {
|
||||||
|
localKey: LS.onboardingDismissed,
|
||||||
|
parse: (raw) => raw === true || raw === "1" || raw === 1,
|
||||||
|
};
|
||||||
|
|
||||||
export function shouldAutoOpenOnboarding(me: {
|
export function shouldAutoOpenOnboarding(
|
||||||
can_read: boolean;
|
me: { can_read: boolean; google_enabled?: boolean },
|
||||||
google_enabled?: boolean;
|
dismissed: boolean,
|
||||||
}): boolean {
|
): boolean {
|
||||||
// YouTube connect needs Google OAuth configured on this instance. A self-host instance may run
|
// YouTube connect needs Google OAuth configured on this instance. A self-host instance may run
|
||||||
// email+password only — then there's nothing to connect, so don't nudge.
|
// email+password only — then there's nothing to connect, so don't nudge.
|
||||||
if (me.google_enabled === false) return false;
|
if (me.google_enabled === false) return false;
|
||||||
if (sessionStorage.getItem(ONBOARD_ACTIVE)) return true;
|
if (sessionStorage.getItem(ONBOARD_ACTIVE)) return true;
|
||||||
return !me.can_read && !getAccountRaw(ONBOARD_DISMISSED);
|
return !me.can_read && !dismissed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mark a grant as in progress, then send the user to Google's consent screen. */
|
/** Mark a grant as in progress, then send the user to Google's consent screen. */
|
||||||
@@ -37,8 +42,7 @@ export function beginGrant(access: "read" | "write"): void {
|
|||||||
connectGoogle(access);
|
connectGoogle(access);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Leave the wizard. `dismiss` suppresses the future auto-popup (used when read is skipped). */
|
/** Leave the wizard's flow (its dismissal, if any, is the caller's preference to set). */
|
||||||
export function endOnboarding(dismiss: boolean): void {
|
export function endOnboarding(): void {
|
||||||
sessionStorage.removeItem(ONBOARD_ACTIVE);
|
sessionStorage.removeItem(ONBOARD_ACTIVE);
|
||||||
if (dismiss) setAccountRaw(ONBOARD_DISMISSED, "1");
|
|
||||||
}
|
}
|
||||||
|
|||||||
+94
-62
@@ -1,100 +1,129 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
// Central registry of every localStorage key the app uses. One place to see them all, rename
|
// Central registry of every key the app keeps in the browser (localStorage, and a tab's
|
||||||
// safely, and avoid collisions — instead of `"siftlode.x"` string literals scattered across
|
// sessionStorage). One place to see them all, rename safely, and avoid collisions — instead of
|
||||||
// files. Parametric (per-user) keys are functions. All keys are namespaced `siftlode.*`.
|
// `"siftlode.x"` string literals scattered across files. All keys are namespaced `siftlode.*`, and each
|
||||||
export const LS = {
|
// declares its kind: what it is decides where it belongs, and a contract test holds the rule per kind
|
||||||
theme: "siftlode.theme",
|
// (storageKinds.contract.test.ts).
|
||||||
sidebarLayout: "siftlode.sidebarLayout",
|
// pref a deliberate choice about the account (theme, a list's layout): it lives on the server and
|
||||||
plexLayout: "siftlode.plexLayout",
|
// follows the account to every device — usePref, whose local copy only carries a value
|
||||||
playlistsLayout: "siftlode.playlistsLayout",
|
// this browser held before the server did.
|
||||||
playlistsCollapsed: "siftlode.playlistsCollapsed",
|
// device right for this screen, speaker or GPU and wrong on another (a panel width, the volume,
|
||||||
filesLayout: "siftlode.filesLayout",
|
// performance mode): this browser only.
|
||||||
filesCollapsed: "siftlode.filesCollapsed",
|
// view where the reader is (page, filters, a selected list, a sub-tab): this browser only.
|
||||||
|
// cache a copy of server data for a fast first paint, rebuilt freely.
|
||||||
|
// public the public /watch page's, which has no account: kept whoever signs in.
|
||||||
|
export type StorageKind = "pref" | "device" | "view" | "cache" | "public";
|
||||||
|
|
||||||
|
const KEYS = {
|
||||||
|
theme: { key: "siftlode.theme", kind: "pref" },
|
||||||
|
sidebarLayout: { key: "siftlode.sidebarLayout", kind: "device" },
|
||||||
|
plexLayout: { key: "siftlode.plexLayout", kind: "device" },
|
||||||
|
playlistsLayout: { key: "siftlode.playlistsLayout", kind: "device" },
|
||||||
|
playlistsCollapsed: { key: "siftlode.playlistsCollapsed", kind: "pref" },
|
||||||
|
filesLayout: { key: "siftlode.filesLayout", kind: "device" },
|
||||||
|
filesCollapsed: { key: "siftlode.filesCollapsed", kind: "pref" },
|
||||||
// The Files view last open (every file, shared with me, or a folder id), per-account.
|
// The Files view last open (every file, shared with me, or a folder id), per-account.
|
||||||
filesView: "siftlode.filesView",
|
filesView: { key: "siftlode.filesView", kind: "view" },
|
||||||
// The Files table's rows-per-page and page (DataTable's persist blob), per-account.
|
// The Files table's rows-per-page and page (DataTable's persist blob), per-account.
|
||||||
filesTable: "siftlode.filesTable",
|
filesTable: { key: "siftlode.filesTable", kind: "device" },
|
||||||
// List or grid per Files view (a `{ viewParam: layout }` map), the sort both paints share, and the
|
// List or grid per Files view (a `{ viewParam: layout }` map), the sort both paints share, and the
|
||||||
// grid's cards per page — per-account mirrors of the server prefs (the last is local only).
|
// grid's cards per page — per-account mirrors of the server prefs (the last is local only).
|
||||||
filesLayouts: "siftlode.filesLayouts",
|
filesLayouts: { key: "siftlode.filesLayouts", kind: "pref" },
|
||||||
filesSort: "siftlode.filesSort",
|
filesSort: { key: "siftlode.filesSort", kind: "pref" },
|
||||||
filesCardSize: "siftlode.filesCardSize",
|
filesCardSize: { key: "siftlode.filesCardSize", kind: "device" },
|
||||||
// The download tray open or folded to its pill — a per-account mirror of the server pref.
|
// The download tray open or folded to its pill — a per-account mirror of the server pref.
|
||||||
downloadTray: "siftlode.downloadTray",
|
downloadTray: { key: "siftlode.downloadTray", kind: "pref" },
|
||||||
// The queue the reader closed the tray on (job ids, a failure marked) — it stays shut until that
|
// The queue the reader closed the tray on (job ids, a failure marked) — it stays shut until that
|
||||||
// changes. Per-account mirror of the server pref.
|
// changes. Per-account mirror of the server pref.
|
||||||
downloadTrayClosed: "siftlode.downloadTrayClosed",
|
downloadTrayClosed: { key: "siftlode.downloadTrayClosed", kind: "pref" },
|
||||||
// The download format used last — the download dialog opens on it. Per-account mirror of the
|
// The download format used last — the download dialog opens on it. Per-account mirror of the
|
||||||
// server pref.
|
// server pref.
|
||||||
downloadProfile: "siftlode.downloadProfile",
|
downloadProfile: { key: "siftlode.downloadProfile", kind: "pref" },
|
||||||
hints: "siftlode.hints",
|
hints: { key: "siftlode.hints", kind: "pref" },
|
||||||
lang: "siftlode.lang",
|
lang: { key: "siftlode.lang", kind: "pref" },
|
||||||
filters: "siftlode.filters",
|
filters: { key: "siftlode.filters", kind: "view" },
|
||||||
page: "siftlode.page",
|
page: { key: "siftlode.page", kind: "view" },
|
||||||
// Start every session on the Home surface rather than on the module you happened to leave
|
// Start every session on the Home surface rather than on the module you happened to leave
|
||||||
// open. Per-account, "0" = opt out and go back to remembering the last module. Read by
|
// open. Per-account, "0" = opt out and go back to remembering the last module. Read by
|
||||||
// `loadInitialPage` in NavigationProvider's initializer, which BootGate runs only once the account
|
// `loadInitialPage` in NavigationProvider's initializer, which BootGate runs only once the account
|
||||||
// is claimed (claimLocalData) — so the landing is decided right before the first paint, never
|
// is claimed (claimLocalData) — so the landing is decided right before the first paint, never
|
||||||
// corrected after.
|
// corrected after.
|
||||||
startOnHome: "siftlode.startOnHome",
|
startOnHome: { key: "siftlode.startOnHome", kind: "pref" },
|
||||||
perfMode: "siftlode.perfMode",
|
perfMode: { key: "siftlode.perfMode", kind: "device" },
|
||||||
// The feed's view mode (see lib/feedView.ts) — a local copy of the server pref, which usePref seeds
|
// The feed's view mode (see lib/feedView.ts) — a local copy of the server pref, which usePref seeds
|
||||||
// from only when the server has never stored the key.
|
// from only when the server has never stored the key.
|
||||||
feedView: "siftlode.feedView",
|
feedView: { key: "siftlode.feedView", kind: "pref" },
|
||||||
// The subscribed table's column filters (a FilterMap), lifted out of the table so the top status
|
// The subscribed table's column filters (a FilterMap), lifted out of the table so the top status
|
||||||
// chips, the Sync column funnel, and the card layout all share ONE state (per-account). Supersedes
|
// chips, the Sync column funnel, and the card layout all share ONE state (per-account). Supersedes
|
||||||
// the old single-string `channelFilter` sync chip.
|
// the old single-string `channelFilter` sync chip.
|
||||||
channelsFilterMap: "siftlode.channelsFilterMap",
|
channelsFilterMap: { key: "siftlode.channelsFilterMap", kind: "view" },
|
||||||
// DEPRECATED — the retired single-value sync chip; read once to migrate a pinned value into
|
// DEPRECATED — the retired single-value sync chip; read once to migrate a pinned value into
|
||||||
// `channelsFilterMap`, then inert. Do not write.
|
// `channelsFilterMap`, then inert. Do not write.
|
||||||
legacyChannelFilter: "siftlode.channelFilter",
|
legacyChannelFilter: { key: "siftlode.channelFilter", kind: "view" },
|
||||||
channelsView: "siftlode.channelsView",
|
channelsView: { key: "siftlode.channelsView", kind: "view" },
|
||||||
// The channel manager's layout (table / cards — see lib/channelLayout.ts), per-account.
|
// The channel manager's layout (table / cards — see lib/channelLayout.ts), per-account.
|
||||||
channelLayout: "siftlode.channelLayout",
|
channelLayout: { key: "siftlode.channelLayout", kind: "pref" },
|
||||||
// The manager's sort, lifted out of the table so the card layout shares it (per-account, per-tab).
|
// The manager's sort, lifted out of the table so the card layout shares it (per-account, per-tab).
|
||||||
channelSort: "siftlode.channelSort",
|
channelSort: { key: "siftlode.channelSort", kind: "view" },
|
||||||
channelShow: "siftlode.channelShow",
|
channelShow: { key: "siftlode.channelShow", kind: "pref" },
|
||||||
channelDiscoverySort: "siftlode.channelDiscoverySort",
|
channelDiscoverySort: { key: "siftlode.channelDiscoverySort", kind: "pref" },
|
||||||
// Cards-per-page for the card layout (the table keeps its own rows-per-page in its persist blob).
|
// Cards-per-page for the card layout (the table keeps its own rows-per-page in its persist blob).
|
||||||
channelCardSize: "siftlode.channelCardSize",
|
channelCardSize: { key: "siftlode.channelCardSize", kind: "device" },
|
||||||
channelDiscoveryCardSize: "siftlode.channelDiscoveryCardSize",
|
channelDiscoveryCardSize: { key: "siftlode.channelDiscoveryCardSize", kind: "device" },
|
||||||
channelsTable: "siftlode.channelsTable",
|
channelsTable: { key: "siftlode.channelsTable", kind: "device" },
|
||||||
// Channel id → name as last seen (useChannelNames): names a channel filter's chips after a reload,
|
// Channel id → name as last seen (useChannelNames): names a channel filter's chips after a reload,
|
||||||
// before the subscription list has landed. A cache, rewritten from the list on every load.
|
// before the subscription list has landed. A cache, rewritten from the list on every load.
|
||||||
channelNames: "siftlode.channelNames",
|
channelNames: { key: "siftlode.channelNames", kind: "cache" },
|
||||||
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
|
channelDiscoveryTable: { key: "siftlode.channelDiscoveryTable", kind: "device" },
|
||||||
// A page's sub-tab: sessionStorage, per browser tab (useTabPersistedState), like adminUsersTab
|
// A page's sub-tab: sessionStorage, per browser tab (useTabPersistedState), like adminUsersTab
|
||||||
// below.
|
// below.
|
||||||
settingsTab: "siftlode.settingsTab",
|
settingsTab: { key: "siftlode.settingsTab", kind: "view" },
|
||||||
configTab: "siftlode.configTab",
|
configTab: { key: "siftlode.configTab", kind: "view" },
|
||||||
adminUsersTab: "siftlode.adminUsersTab",
|
adminUsersTab: { key: "siftlode.adminUsersTab", kind: "view" },
|
||||||
filterCollapsed: "siftlode.filterCollapsed",
|
filterCollapsed: { key: "siftlode.filterCollapsed", kind: "pref" },
|
||||||
playlist: "siftlode.playlist",
|
playlist: { key: "siftlode.playlist", kind: "view" },
|
||||||
plSort: "siftlode.plSort",
|
plSort: { key: "siftlode.plSort", kind: "pref" },
|
||||||
plexLibrary: "siftlode.plexLibrary",
|
plexLibrary: { key: "siftlode.plexLibrary", kind: "view" },
|
||||||
plexShow: "siftlode.plexShow",
|
plexShow: { key: "siftlode.plexShow", kind: "view" },
|
||||||
plexSort: "siftlode.plexSort",
|
plexSort: { key: "siftlode.plexSort", kind: "view" },
|
||||||
plexFilters: "siftlode.plexFilters",
|
plexFilters: { key: "siftlode.plexFilters", kind: "view" },
|
||||||
plexQ: "siftlode.plexQ",
|
plexQ: { key: "siftlode.plexQ", kind: "view" },
|
||||||
plexField: "siftlode.plexField",
|
plexField: { key: "siftlode.plexField", kind: "view" },
|
||||||
plexPlaylistLayout: "siftlode.plexPlaylistLayout",
|
plexPlaylistLayout: { key: "siftlode.plexPlaylistLayout", kind: "pref" },
|
||||||
plexPlayerPrefs: "siftlode.plexPlayerPrefs",
|
plexPlayerPrefs: { key: "siftlode.plexPlayerPrefs", kind: "device" },
|
||||||
// The YouTube modal player's volume (`{ volume: 0–100 }`; 0 IS muted — there is no separate
|
// The YouTube modal player's volume (`{ volume: 0–100 }`; 0 IS muted — there is no separate
|
||||||
// flag). A YT.Player instance is created per video and starts at full volume, so without this
|
// flag). A YT.Player instance is created per video and starts at full volume, so without this
|
||||||
// every next video is loud again (same idea as plexPlayerPrefs).
|
// every next video is loud again (same idea as plexPlayerPrefs).
|
||||||
ytPlayerPrefs: "siftlode.ytPlayerPrefs",
|
ytPlayerPrefs: { key: "siftlode.ytPlayerPrefs", kind: "device" },
|
||||||
notifHistory: "siftlode.notifications",
|
notifHistory: { key: "siftlode.notifications", kind: "cache" },
|
||||||
notifSettings: "siftlode.notifSettings",
|
notifSettings: { key: "siftlode.notifSettings", kind: "pref" },
|
||||||
onboardingDismissed: "siftlode.onboarding.dismissed",
|
onboardingDismissed: { key: "siftlode.onboarding.dismissed", kind: "pref" },
|
||||||
seenVersion: "siftlode.seenVersion",
|
seenVersion: { key: "siftlode.seenVersion", kind: "pref" },
|
||||||
// The public /watch page's repeat toggle (JSON boolean; on by default). Browser-wide, NOT per account:
|
// The public /watch page's repeat toggle (JSON boolean; on by default). Browser-wide, NOT per account:
|
||||||
// that page runs outside the app, with no account at all.
|
// that page runs outside the app, with no account at all.
|
||||||
watchRepeat: "siftlode.watchRepeat",
|
watchRepeat: { key: "siftlode.watchRepeat", kind: "public" },
|
||||||
// Where this browser stopped in each shared /watch link (`lib/resume.ts`: token -> {at, t}). Same
|
// Where this browser stopped in each shared /watch link (`lib/resume.ts`: token -> {at, t}). Same
|
||||||
// reason as above: the viewer has no account to keep it in.
|
// reason as above: the viewer has no account to keep it in.
|
||||||
watchResume: "siftlode.watchResume",
|
watchResume: { key: "siftlode.watchResume", kind: "public" },
|
||||||
} as const;
|
// A tab's own state, kept in sessionStorage outside the hooks above: the onboarding flow in
|
||||||
|
// progress across Google's consent redirect, the debug recorder's steps, the log's tab id.
|
||||||
|
onboardingActive: { key: "siftlode.onboarding.active", kind: "view" },
|
||||||
|
debugSteps: { key: "siftlode.debugSteps", kind: "view" },
|
||||||
|
logTab: { key: "siftlode.logTab", kind: "device" },
|
||||||
|
} as const satisfies Record<string, { key: `siftlode.${string}`; kind: StorageKind }>;
|
||||||
|
|
||||||
|
/** Every key the app stores, by name. */
|
||||||
|
export const LS = Object.fromEntries(
|
||||||
|
Object.entries(KEYS).map(([name, { key }]) => [name, key]),
|
||||||
|
) as {
|
||||||
|
readonly [N in keyof typeof KEYS]: (typeof KEYS)[N]["key"];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Each stored key's kind (see StorageKind), for the rules that hold per kind. */
|
||||||
|
export const KIND_OF: ReadonlyMap<string, StorageKind> = new Map(
|
||||||
|
Object.values(KEYS).map(({ key, kind }) => [key, kind]),
|
||||||
|
);
|
||||||
|
|
||||||
// --- the signed-in account's local state --------------------------------------------------
|
// --- the signed-in account's local state --------------------------------------------------
|
||||||
// A browser session holds ONE account, so its local state needs no account id in the key: the browser
|
// A browser session holds ONE account, so its local state needs no account id in the key: the browser
|
||||||
@@ -105,12 +134,15 @@ export const LS = {
|
|||||||
|
|
||||||
const OWNER_KEY = "siftlode.owner";
|
const OWNER_KEY = "siftlode.owner";
|
||||||
// Kept whoever signs in: they belong to the browser, not to an account (the public /watch page has none).
|
// Kept whoever signs in: they belong to the browser, not to an account (the public /watch page has none).
|
||||||
const BROWSER_KEYS: ReadonlySet<string> = new Set([LS.watchRepeat, LS.watchResume, OWNER_KEY]);
|
const BROWSER_KEYS: ReadonlySet<string> = new Set([
|
||||||
|
OWNER_KEY,
|
||||||
|
...[...KIND_OF].flatMap(([key, kind]) => (kind === "public" ? [key] : [])),
|
||||||
|
]);
|
||||||
// The multi-account build pinned each tab to an account (sessionStorage) and suffixed every key with
|
// The multi-account build pinned each tab to an account (sessionStorage) and suffixed every key with
|
||||||
// its id (`siftlode.filters.5`). A claim moves the account's own copies onto the plain keys and drops
|
// its id (`siftlode.filters.5`). A claim moves the account's own copies onto the plain keys and drops
|
||||||
// the other accounts' copies; only keys the registry knows are touched.
|
// the other accounts' copies; only keys the registry knows are touched.
|
||||||
const LEGACY_PIN_KEYS = ["siftlode.activeAccount", "siftlode.nextAccount"];
|
const LEGACY_PIN_KEYS = ["siftlode.activeAccount", "siftlode.nextAccount"];
|
||||||
const REGISTERED: ReadonlySet<string> = new Set(Object.values(LS));
|
const REGISTERED: ReadonlySet<string> = new Set(KIND_OF.keys());
|
||||||
const ACCOUNT_COPY = /^(.+)\.(\d+)$/;
|
const ACCOUNT_COPY = /^(.+)\.(\d+)$/;
|
||||||
|
|
||||||
let claimed: string | null = null;
|
let claimed: string | null = null;
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { KIND_OF, LS } from "./storage";
|
||||||
|
|
||||||
|
// Every key the app keeps in the browser is declared in storage.ts with its kind (a key without one
|
||||||
|
// does not compile), and the kind decides where it belongs. This holds the two ways that rule drifts:
|
||||||
|
// a key spelled out somewhere else, so the registry never learns of it, and an account preference
|
||||||
|
// read or written straight from localStorage, so it never reaches the server and stays on one device
|
||||||
|
// while every other device shows something else.
|
||||||
|
|
||||||
|
const SRC = new URL("../", import.meta.url);
|
||||||
|
const SKIP_DIRS = new Set(["node_modules", "design-lab"]);
|
||||||
|
const TEST = /\.test\.tsx?$/;
|
||||||
|
|
||||||
|
function files(dir: string): string[] {
|
||||||
|
return readdirSync(new URL(dir, SRC), { withFileTypes: true }).flatMap((e) =>
|
||||||
|
e.isDirectory()
|
||||||
|
? SKIP_DIRS.has(e.name)
|
||||||
|
? []
|
||||||
|
: files(`${dir}${e.name}/`)
|
||||||
|
: /\.tsx?$/.test(e.name) && !TEST.test(e.name)
|
||||||
|
? [`${dir}${e.name}`]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const SOURCES = files("").map((f) => ({ f, text: readFileSync(new URL(f, SRC), "utf-8") }));
|
||||||
|
|
||||||
|
// Preferences the Settings page owns (PrefsProvider saves them to the server) whose local copy is
|
||||||
|
// kept by the store that applies them: it paints the first frame, and for the language the
|
||||||
|
// signed-out pages too, before the account is known.
|
||||||
|
const SYNCED_BY_STORE: Record<string, string> = {
|
||||||
|
theme: "lib/theme.ts",
|
||||||
|
hints: "lib/hints.ts",
|
||||||
|
notifSettings: "lib/notifications.ts",
|
||||||
|
lang: "i18n/index.ts",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("storage kinds", () => {
|
||||||
|
it("spells out no key outside the registry", () => {
|
||||||
|
// The debug recorder's BroadcastChannel is a channel name, not a stored key.
|
||||||
|
const allowed = new Set(['"siftlode.debugSession"']);
|
||||||
|
const hits = SOURCES.filter(({ f }) => f !== "lib/storage.ts").flatMap(({ f, text }) =>
|
||||||
|
[...text.matchAll(/["'`]siftlode\.[\w.]+["'`]/g)]
|
||||||
|
.map((m) => m[0])
|
||||||
|
.filter((lit) => !allowed.has(lit))
|
||||||
|
.map((lit) => `${f}: ${lit}`),
|
||||||
|
);
|
||||||
|
expect(hits).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an account preference on the server: its local copy only through usePref", () => {
|
||||||
|
const prefs = Object.entries(LS).filter(([, key]) => KIND_OF.get(key) === "pref");
|
||||||
|
expect(prefs.length).toBeGreaterThan(10);
|
||||||
|
const hits = prefs.flatMap(([name]) =>
|
||||||
|
SOURCES.flatMap(({ f, text }) => {
|
||||||
|
if (f === "lib/storage.ts" || SYNCED_BY_STORE[name] === f) return [];
|
||||||
|
const named = text.match(new RegExp(`\\bLS\\.${name}\\b`, "g"))?.length ?? 0;
|
||||||
|
const asLocalCopy =
|
||||||
|
text.match(new RegExp(`\\blocalKey:\\s*LS\\.${name}\\b`, "g"))?.length ?? 0;
|
||||||
|
return named > asLocalCopy ? [`${f}: LS.${name}`] : [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(hits).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists only preferences as kept by their store", () => {
|
||||||
|
for (const name of Object.keys(SYNCED_BY_STORE))
|
||||||
|
expect(KIND_OF.get(LS[name as keyof typeof LS]), name).toBe("pref");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The e2e setup seeds a browser's state by hand: a key it writes that the app no longer reads would
|
||||||
|
// keep a spec green while setting up nothing.
|
||||||
|
it("seeds the e2e browsers with registered keys only", () => {
|
||||||
|
const setup = readFileSync(new URL("../e2e/global.setup.ts", SRC), "utf-8");
|
||||||
|
const keys = [...setup.matchAll(/["'`](siftlode\.[\w.]+)["'`]/g)].map((m) => m[1]!);
|
||||||
|
expect(keys.length).toBeGreaterThan(0);
|
||||||
|
expect(keys.filter((k) => k !== "siftlode.owner" && !KIND_OF.has(k))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,10 +17,7 @@ const { store, savePrefs } = vi.hoisted(() => ({
|
|||||||
// Partial: i18n (which usePref now imports, to title a refused save) reads its key from `LS`.
|
// Partial: i18n (which usePref now imports, to title a refused save) reads its key from `LS`.
|
||||||
vi.mock("./storage", async (importOriginal) => ({
|
vi.mock("./storage", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("./storage")>()),
|
...(await importOriginal<typeof import("./storage")>()),
|
||||||
readAccount: (base: string, fallback: unknown) => {
|
getAccountRaw: (base: string) => store.get(base) ?? null,
|
||||||
const raw = store.get(base);
|
|
||||||
return raw == null ? fallback : JSON.parse(raw);
|
|
||||||
},
|
|
||||||
writeAccount: (base: string, value: unknown) => {
|
writeAccount: (base: string, value: unknown) => {
|
||||||
store.set(base, JSON.stringify(value));
|
store.set(base, JSON.stringify(value));
|
||||||
},
|
},
|
||||||
@@ -77,6 +74,52 @@ describe("usePref", () => {
|
|||||||
expect(result.current[0]).toBe("tiles");
|
expect(result.current[0]).toBe("tiles");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A copy written before usePref held the key is a bare string ("cards", "1"), not JSON.
|
||||||
|
it("reads a bare-string copy as that string, through parse", () => {
|
||||||
|
const qc = makeClient();
|
||||||
|
store.set("siftlode.plexPlaylistLayout", "tree");
|
||||||
|
const { result } = renderHook(
|
||||||
|
() =>
|
||||||
|
usePref<string>("plexPlaylistLayout", "accordion", {
|
||||||
|
localKey: "siftlode.plexPlaylistLayout",
|
||||||
|
parse: (raw) => (raw === "tree" ? "tree" : "accordion"),
|
||||||
|
}),
|
||||||
|
{ wrapper: wrapperFor(qc) },
|
||||||
|
);
|
||||||
|
expect(result.current[0]).toBe("tree");
|
||||||
|
});
|
||||||
|
|
||||||
|
// A value only this browser held reaches the server, so the account's other devices get it: once,
|
||||||
|
// and only while the server has none.
|
||||||
|
it("saves a value only this browser held to the server, once", () => {
|
||||||
|
const qc = makeClient();
|
||||||
|
seedMe(qc, {});
|
||||||
|
store.set("siftlode.feedView", JSON.stringify("tiles"));
|
||||||
|
const useViewPref = () => usePref<string>("view", "cards", { localKey: "siftlode.feedView" });
|
||||||
|
renderHook(useViewPref, { wrapper: wrapperFor(qc) });
|
||||||
|
expect(savePrefs).toHaveBeenCalledExactlyOnceWith({ view: "tiles" });
|
||||||
|
expect(qc.getQueryData<{ preferences: Record<string, unknown> }>(qk.me())?.preferences).toEqual(
|
||||||
|
{ view: "tiles" },
|
||||||
|
);
|
||||||
|
|
||||||
|
renderHook(useViewPref, { wrapper: wrapperFor(qc) }); // a second instance: the server has it now
|
||||||
|
expect(savePrefs).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends nothing at mount when the server has the value, or nobody has one", () => {
|
||||||
|
const qc = makeClient();
|
||||||
|
seedMe(qc, { view: "rows" });
|
||||||
|
store.set("siftlode.feedView", JSON.stringify("tiles"));
|
||||||
|
renderHook(() => usePref<string>("view", "cards", { localKey: "siftlode.feedView" }), {
|
||||||
|
wrapper: wrapperFor(qc),
|
||||||
|
});
|
||||||
|
store.clear();
|
||||||
|
renderHook(() => usePref<string>("other", "x", { localKey: "siftlode.feedView" }), {
|
||||||
|
wrapper: wrapperFor(qc),
|
||||||
|
});
|
||||||
|
expect(savePrefs).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("prefers the server value over the mirror", () => {
|
it("prefers the server value over the mirror", () => {
|
||||||
const qc = makeClient();
|
const qc = makeClient();
|
||||||
seedMe(qc, { view: "rows" });
|
seedMe(qc, { view: "rows" });
|
||||||
|
|||||||
+57
-16
@@ -3,7 +3,7 @@ import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
|||||||
import { qk } from "./queryKeys";
|
import { qk } from "./queryKeys";
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
import type { Me } from "./api/account";
|
import type { Me } from "./api/account";
|
||||||
import { readAccount, writeAccount } from "./storage";
|
import { getAccountRaw, writeAccount } from "./storage";
|
||||||
import { notifyActionError } from "./actionErrors";
|
import { notifyActionError } from "./actionErrors";
|
||||||
import { updateMeCache } from "./useMe";
|
import { updateMeCache } from "./useMe";
|
||||||
import i18n from "../i18n";
|
import i18n from "../i18n";
|
||||||
@@ -26,8 +26,8 @@ const reportPrefSaveFailure = (err: unknown) =>
|
|||||||
// consumers reading the same key (e.g. a second player instance) see the change at once.
|
// consumers reading the same key (e.g. a second player instance) see the change at once.
|
||||||
|
|
||||||
export interface UsePrefOptions<T> {
|
export interface UsePrefOptions<T> {
|
||||||
/** Also mirror to per-account localStorage under this base key; that copy seeds the value when the
|
/** Also mirror to the account's localStorage under this base key; that copy seeds the value (and
|
||||||
* server payload lacks the key. */
|
* the server, once) when the server payload lacks the key. */
|
||||||
localKey?: string;
|
localKey?: string;
|
||||||
/** Debounce the server PUT by N ms so a slider drag / rapid toggles coalesce into one request. The
|
/** Debounce the server PUT by N ms so a slider drag / rapid toggles coalesce into one request. The
|
||||||
* local state + cache still update instantly; only the network write is deferred. */
|
* local state + cache still update instantly; only the network write is deferred. */
|
||||||
@@ -41,6 +41,45 @@ function readServerPref(me: Me | null | undefined, key: string): unknown {
|
|||||||
return me?.preferences?.[key];
|
return me?.preferences?.[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The local copy as stored: JSON, or a bare string written before this hook held the key (a layout
|
||||||
|
// name, "1"), which is kept as that string for `parse` to map. Undefined when there is none.
|
||||||
|
function readLocalCopy(localKey: string): unknown {
|
||||||
|
const raw = getAccountRaw(localKey);
|
||||||
|
if (raw == null) return undefined;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as unknown;
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a preference's value comes from: the server's, then the browser's own copy (a value it held
|
||||||
|
* before the server did, which `usePref` then saves to the server once), then the default. */
|
||||||
|
function resolvePref<T>(
|
||||||
|
me: Me | null | undefined,
|
||||||
|
serverKey: string,
|
||||||
|
defaultValue: T,
|
||||||
|
{ localKey, parse }: UsePrefOptions<T>,
|
||||||
|
): { value: T; fromLocal: boolean } {
|
||||||
|
const norm = (raw: unknown): T => (parse ? parse(raw) : (raw as T));
|
||||||
|
const server = readServerPref(me, serverKey);
|
||||||
|
if (server !== undefined) return { value: norm(server), fromLocal: false };
|
||||||
|
const local = localKey ? readLocalCopy(localKey) : undefined;
|
||||||
|
if (local !== undefined) return { value: norm(local), fromLocal: true };
|
||||||
|
return { value: defaultValue, fromLocal: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A preference's value outside a component (a landing page picked before the first render, a nudge
|
||||||
|
* decided from the account): the same order `usePref` seeds from. */
|
||||||
|
export function prefValue<T>(
|
||||||
|
me: Me | null | undefined,
|
||||||
|
serverKey: string,
|
||||||
|
defaultValue: T,
|
||||||
|
options: UsePrefOptions<T> = {},
|
||||||
|
): T {
|
||||||
|
return resolvePref(me, serverKey, defaultValue, options).value;
|
||||||
|
}
|
||||||
|
|
||||||
// Optimistically merge a preferences patch into the `me` cache so readers see it without a refetch —
|
// Optimistically merge a preferences patch into the `me` cache so readers see it without a refetch —
|
||||||
// the shape mirrors the server's PUT /api/me/preferences merge. Shared by useSavePref and usePref.set
|
// the shape mirrors the server's PUT /api/me/preferences merge. Shared by useSavePref and usePref.set
|
||||||
// (their sole difference is WHEN the server write fires — immediately vs debounced), so the cache-merge
|
// (their sole difference is WHEN the server write fires — immediately vs debounced), so the cache-merge
|
||||||
@@ -71,21 +110,23 @@ export function usePref<T>(
|
|||||||
defaultValue: T,
|
defaultValue: T,
|
||||||
options: UsePrefOptions<T> = {},
|
options: UsePrefOptions<T> = {},
|
||||||
): [T, (next: T) => void] {
|
): [T, (next: T) => void] {
|
||||||
const { localKey, debounceMs, parse } = options;
|
const { localKey, debounceMs } = options;
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
// Seeded inline rather than through a memoized `seed()`: useState calls a lazy initializer exactly
|
// Read once, at mount (`parse` is read here only: the setter stores what it is given). Server
|
||||||
// once, at mount, so there was nothing for the useCallback to save — and its dep list was the one
|
// preference wins, then this browser's copy, then the default.
|
||||||
// place in this file that had to be excused from the checker (it read `defaultValue` without
|
const [seed] = useState(() =>
|
||||||
// naming it). Server preference wins, then the per-account localStorage mirror, then the default.
|
resolvePref(qc.getQueryData<Me | null>(qk.me()), serverKey, defaultValue, options),
|
||||||
const [value, setValue] = useState<T>(() => {
|
);
|
||||||
// `parse` is read here only, once: the setter stores what it is given.
|
const [value, setValue] = useState<T>(seed.value);
|
||||||
const norm = (raw: unknown): T => (parse ? parse(raw) : (raw as T));
|
// A value only this browser held (the server has never stored the key) is saved to the server once,
|
||||||
const server = readServerPref(qc.getQueryData<Me | null>(qk.me()), serverKey);
|
// so the account's other devices get it too; from then on the server's wins everywhere.
|
||||||
if (server !== undefined) return norm(server);
|
useEffect(() => {
|
||||||
if (localKey) return norm(readAccount(localKey, defaultValue));
|
if (!seed.fromLocal) return;
|
||||||
return defaultValue;
|
const payload = { [serverKey]: seed.value };
|
||||||
});
|
mergePrefsCache(qc, payload);
|
||||||
|
api.savePrefs(payload).catch(reportPrefSaveFailure);
|
||||||
|
}, [seed, serverKey, qc]);
|
||||||
|
|
||||||
const saveTimer = useRef<number | undefined>(undefined);
|
const saveTimer = useRef<number | undefined>(undefined);
|
||||||
// The pending debounced server write, if any — flushed (NOT dropped) on unmount so the last change
|
// The pending debounced server write, if any — flushed (NOT dropped) on unmount so the last change
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
// Build info baked into the SPA at build time (Vite inlines VITE_*-prefixed env).
|
// Build info baked into the SPA at build time (Vite inlines VITE_*-prefixed env).
|
||||||
// Injected via Docker build-args (see Dockerfile); falls back to "dev" for un-stamped builds.
|
// Injected via Docker build-args (see Dockerfile); falls back to "dev" for un-stamped builds.
|
||||||
import { LS } from "./storage";
|
import { LS } from "./storage";
|
||||||
|
import type { UsePrefOptions } from "./usePref";
|
||||||
|
|
||||||
export const FRONTEND_VERSION = import.meta.env.VITE_APP_VERSION || "dev";
|
export const FRONTEND_VERSION = import.meta.env.VITE_APP_VERSION || "dev";
|
||||||
export const FRONTEND_SHA = import.meta.env.VITE_GIT_SHA || "unknown";
|
export const FRONTEND_SHA = import.meta.env.VITE_GIT_SHA || "unknown";
|
||||||
export const FRONTEND_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || "";
|
export const FRONTEND_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || "";
|
||||||
|
|
||||||
// Key for the "last version the user has seen release notes for" (drives the banner).
|
// The last version the account has seen the release notes banner for — an account preference
|
||||||
export const SEEN_VERSION_KEY = LS.seenVersion;
|
// (usePref), so a banner dismissed on one device stays dismissed on the others.
|
||||||
|
export const SEEN_VERSION_PREF = "seenVersion";
|
||||||
|
export const SEEN_VERSION_OPTIONS: UsePrefOptions<string | null> = {
|
||||||
|
localKey: LS.seenVersion,
|
||||||
|
parse: (raw) => (raw == null ? null : String(raw)),
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user