diff --git a/frontend/e2e/devices.spec.ts b/frontend/e2e/devices.spec.ts new file mode 100644 index 00000000..9d018546 --- /dev/null +++ b/frontend/e2e/devices.spec.ts @@ -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 { + 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 { + 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(); + } +}); diff --git a/frontend/e2e/settings.spec.ts b/frontend/e2e/settings.spec.ts index 39b7de3c..8e71b623 100644 --- a/frontend/e2e/settings.spec.ts +++ b/frontend/e2e/settings.spec.ts @@ -5,7 +5,9 @@ import { openApp, gotoPage } from "./helpers"; // 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. 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. const putPrefs = (page: Page) => diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 70358c41..a17c3640 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -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 STORAGE_STATE = "e2e/.auth/state.json"; // 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 -// answers for all, so the other projects leave them out instead of tripling their time. -const CHROMIUM_ONLY = /(a11y|member)\.spec\.ts/; +// member spec checks which pages and requests a role gets, which no engine changes, and the devices +// spec opens its own desktop and iPad contexts: one engine answers for all, so the other projects +// leave them out instead of tripling their time. +const CHROMIUM_ONLY = /(a11y|member|devices)\.spec\.ts/; export default defineConfig({ testDir: "./e2e", diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index 09abd572..a76990dd 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -7,8 +7,8 @@ import { UserPlus } from "lucide-react"; import { api, type DiscoveredChannel } from "../lib/api"; import { LS, ownKey } from "../lib/storage"; import { formatCountOrDash } from "../lib/format"; -import { sortRows } from "../lib/columnSort"; -import { useSortState } from "../lib/useSortState"; +import { hydrateSort, sortRows, type SortState } from "../lib/columnSort"; +import { usePref } from "../lib/usePref"; import { useCardPager } from "../lib/useCardPager"; import { subsColumn } from "./channelColumns"; import { notify } from "../lib/notifications"; @@ -48,7 +48,10 @@ export default function ChannelDiscovery({ // The table/cards density is shared with the Subscriptions tab (ChannelsProvider). The sort is // this tab's own (its columns differ), lifted out of the table so the card layout shares it. const layout = useChannelLayout(); - const [sort, setSort] = useSortState(LS.channelDiscoverySort); + const [sort, setSort] = usePref("channelDiscoverySort", null, { + localKey: LS.channelDiscoverySort, + parse: (raw) => hydrateSort(raw == null ? null : JSON.stringify(raw), []), + }); const query = useQuery({ queryKey: qk.discoveredChannels(), diff --git a/frontend/src/components/ChannelsProvider.dom.test.tsx b/frontend/src/components/ChannelsProvider.dom.test.tsx index f889f967..c4463e6a 100644 --- a/frontend/src/components/ChannelsProvider.dom.test.tsx +++ b/frontend/src/components/ChannelsProvider.dom.test.tsx @@ -6,7 +6,8 @@ // // Uses REAL storage (jsdom localStorage/sessionStorage) with account 1's local state claimed, so // useAccountPersistedState / useSortState / useFilterState all resolve their account-scoped keys; only -// navigationContext (the sole runtime dependency) is mocked, pinned to the channels page. +// navigationContext is mocked, pinned to the channels page. The layout is an account preference +// (usePref), read from the `me` cache BootGate fills before the app mounts. import { describe, it, expect, beforeEach, vi } from "vitest"; import { renderHook, act, render as renderRoot } from "@testing-library/react"; import { useEffect, useEffectEvent, type ReactNode } from "react"; @@ -17,11 +18,19 @@ vi.mock("./navigationContext", () => ({ })); import { ChannelsProvider } from "./ChannelsProvider"; -import { useChannels, useChannelsActions } from "./channelsContext"; +import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext"; import { claimLocalData } from "../lib/storage"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { qk } from "../lib/queryKeys"; +let qc = new QueryClient(); const wrapper = ({ children }: { children: ReactNode }) => ( - {children} + + {children} + +); +const withClient = ({ children }: { children: ReactNode }) => ( + {children} ); const render = () => renderHook(() => ({ ...useChannels(), ...useChannelsActions() }), { wrapper }); const VIEW_KEY = "siftlode.channelsView"; // the account's (claimed in beforeEach) @@ -29,6 +38,8 @@ const VIEW_KEY = "siftlode.channelsView"; // the account's (claimed in beforeEac beforeEach(() => { localStorage.clear(); claimLocalData(1); // the browser holds account 1's state + qc = new QueryClient(); + qc.setQueryData(qk.me(), { id: 1, preferences: {} }); // Reset BOTH the history state and the query string: setView only owns an entry on the channels page, // and the URL mirror writes ctab/csort/cf — a stale key left by a prior test would be read back by // this provider's mount-time URL seed and change the starting tab. @@ -166,6 +177,7 @@ describe("ChannelsProvider — the focus-a-channel intent", () => { , + { wrapper: withClient }, ); act(() => api.focusChannel("Kurzgesagt")); @@ -202,6 +214,7 @@ describe("ChannelsProvider — the focus-a-channel intent", () => { , + { wrapper: withClient }, ); act(() => api.focusChannel("Kurzgesagt")); @@ -210,3 +223,14 @@ describe("ChannelsProvider — the focus-a-channel intent", () => { expect(api.search).toBe("Kurzgesagt"); }); }); + +describe("ChannelsProvider — the layout", () => { + // The table or the cards is a choice about the account, so it follows it to every device: the + // server's word wins over what this browser last showed. + it("is the account's preference, over this browser's copy", () => { + localStorage.setItem("siftlode.channelLayout", "table"); + qc.setQueryData(qk.me(), { id: 1, preferences: { channelLayout: "cards" } }); + const { result } = renderHook(useChannelLayout, { wrapper }); + expect(result.current).toBe("cards"); + }); +}); diff --git a/frontend/src/components/ChannelsProvider.tsx b/frontend/src/components/ChannelsProvider.tsx index 4ea649e7..7aaef10e 100644 --- a/frontend/src/components/ChannelsProvider.tsx +++ b/frontend/src/components/ChannelsProvider.tsx @@ -12,6 +12,7 @@ import { parseChannelLayout } from "../lib/channelLayout"; import { hydrateSort } from "../lib/columnSort"; import { useLatest } from "../lib/useLatest"; import { useSortState } from "../lib/useSortState"; +import { usePref } from "../lib/usePref"; import { useFilterState } from "../lib/useFilterState"; import { useUrlMirror } from "../lib/useUrlMirror"; import { rememberNavPosition } from "../lib/history"; @@ -100,12 +101,15 @@ export function ChannelsProvider({ children }: { children: ReactNode }) { // The subscribed table's column-filter map, owned here (lifted out of DataTable) so the top status // chips, the Sync column funnel, and the card layout share one state; useFilterState persists it. const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip); - // view + layout are persisted tabs (useAccountPersistedState = the shared account-scoped tab-state hook). - // Stored un-clamped; clampView/parseChannelLayout heal them for display. setView layers the history - // logic (below) on top of the raw setter. + // The view is this browser's (useAccountPersistedState), the layout an account preference + // (usePref). Stored un-clamped; clampView/parseChannelLayout heal them for display. setView layers + // the history logic (below) on top of the raw setter. const [viewRaw, setViewRaw] = useAccountPersistedState(LS.channelsView, "subscribed"); const view = clampView(viewRaw); - const [layoutRaw, setLayout] = useAccountPersistedState(LS.channelLayout, "table"); + const [layoutRaw, setLayout] = usePref("channelLayout", "table", { + localKey: LS.channelLayout, + parse: (raw) => (typeof raw === "string" ? raw : "table"), + }); const layout = parseChannelLayout(layoutRaw); // Sort is owned here (lifted out of DataTable) so the card layout shares it; ColumnSortControl heals // it against the live columns for display, so the provider stores it un-healed (no options passed). diff --git a/frontend/src/components/NavigationProvider.dom.test.tsx b/frontend/src/components/NavigationProvider.dom.test.tsx index e4dacc20..41095590 100644 --- a/frontend/src/components/NavigationProvider.dom.test.tsx +++ b/frontend/src/components/NavigationProvider.dom.test.tsx @@ -32,7 +32,8 @@ import { PageHeading } from "./ui/Headings"; import { qk } from "../lib/queryKeys"; import { claimLocalData, LS, setAccountRaw } from "../lib/storage"; -import { PAGE_KEY, START_ON_HOME } from "./navigationInit"; +import { api } from "../lib/api"; +import { PAGE_KEY } from "./navigationInit"; /** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs * to this document, nearest first. jsdom has no Navigation API; Chrome's says this. */ @@ -263,7 +264,12 @@ describe("NavigationProvider · Back", () => { }); it("a remembered retired page opens its tab", () => { - setAccountRaw(LS.startOnHome, START_ON_HOME.off); + qc.setQueryData(qk.me(), { + id: 1, + role: "admin", + plex_enabled: true, + preferences: { startOnHome: false }, + }); setAccountRaw(PAGE_KEY, "logs"); window.history.replaceState(null, "", "/"); const { result } = render(); @@ -271,6 +277,19 @@ describe("NavigationProvider · Back", () => { expect(new URLSearchParams(window.location.search).get("at")).toBe("logs"); }); + // Start-on-Home is the account's preference. An opt-out this browser kept before the server held it + // (the string "0") still counts, and reaches the server for the account's other devices. + it("takes this browser's earlier opt-out of Home, and saves it to the account", () => { + const save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never); + qc.setQueryData(qk.me(), { id: 1, role: "admin", plex_enabled: true, preferences: {} }); + setAccountRaw(LS.startOnHome, "0"); + setAccountRaw(PAGE_KEY, "plex"); + window.history.replaceState(null, "", "/"); + const { result } = render(); + expect(result.current.page).toBe("plex"); + expect(save).toHaveBeenCalledWith({ startOnHome: false }); + }); + it("opens a retired page's tab when Back lands on its entry", () => { const { result } = render(); act(() => result.current.setPage("plex")); diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx index 6602fdfa..059091c2 100644 --- a/frontend/src/components/NavigationProvider.tsx +++ b/frontend/src/components/NavigationProvider.tsx @@ -15,7 +15,15 @@ import { rememberNavPosition, } from "../lib/history"; import { reachablePage } from "../lib/modules"; -import { channelViewFrom, loadInitialPage, PAGE_KEY, type ChannelView } from "./navigationInit"; +import { + channelViewFrom, + loadInitialPage, + PAGE_KEY, + START_ON_HOME_OPTIONS, + START_ON_HOME_PREF, + type ChannelView, +} from "./navigationInit"; +import { usePref } from "../lib/usePref"; import { NavigationActionsContext, NavigationStateContext, @@ -32,7 +40,10 @@ import { export function NavigationProvider({ children }: { children: ReactNode }) { const { t, i18n } = useTranslation(); - const [chosenPage, setPageState] = useState(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(() => loadInitialPage(startOnHome)); // The page on screen: the chosen one when this account may open it, else the feed — derived, so a // link, a Back and a role taken away mid-session all land the same way, and the rail, the content // and the address bar (stamped from it below) agree. diff --git a/frontend/src/components/OnboardingWizard.escape.dom.test.tsx b/frontend/src/components/OnboardingWizard.escape.dom.test.tsx index e092144c..0f911461 100644 --- a/frontend/src/components/OnboardingWizard.escape.dom.test.tsx +++ b/frontend/src/components/OnboardingWizard.escape.dom.test.tsx @@ -33,7 +33,7 @@ afterEach(() => { }); describe("the onboarding wizard's Escape", () => { - const mount = (onClose: () => void, dialog: boolean) => + const mount = (onClose: (dismiss: boolean) => void, dialog: boolean) => render( { const onClose = vi.fn(); mount(onClose, false); fireEvent.keyDown(document, { key: "Escape" }); - expect(onClose).toHaveBeenCalledTimes(1); + // Read was never granted: leaving now stops the wizard opening by itself. + expect(onClose).toHaveBeenCalledExactlyOnceWith(true); }); it("leaves the wizard alone while a dialog is open over it", () => { diff --git a/frontend/src/components/OnboardingWizard.tsx b/frontend/src/components/OnboardingWizard.tsx index 8e60ccb0..bb369336 100644 --- a/frontend/src/components/OnboardingWizard.tsx +++ b/frontend/src/components/OnboardingWizard.tsx @@ -65,7 +65,14 @@ function Dots({ index, total }: { index: number; total: number }) { ); } -export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () => void }) { +export default function OnboardingWizard({ + me, + onClose, +}: { + me: Me; + /** `dismiss`: the essential read step was skipped, so the wizard stops opening by itself. */ + onClose: (dismiss: boolean) => void; +}) { const { t } = useTranslation(); const qc = useQueryClient(); const importTriggered = useRef(false); @@ -110,8 +117,8 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () // Closing without finishing the essential read step suppresses future auto-popups // (it stays reachable from Settings). Once read is granted, closing is just "done". const close = () => { - endOnboarding(!me.can_read); - onClose(); + endOnboarding(); + onClose(!me.can_read); }; const overlayOrder = useOverlayOrder(); // this overlay's toast-host rank (see ui/Toaster) @@ -274,8 +281,8 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () variant="ghost" size="md" onClick={() => { - endOnboarding(false); - onClose(); + endOnboarding(); + onClose(false); }} > {t("onboarding.write.keepReadOnly")} @@ -299,8 +306,8 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () size="lg" fullWidth onClick={() => { - endOnboarding(false); - onClose(); + endOnboarding(); + onClose(false); }} > {t("onboarding.done.done")} diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx index ec8079a7..7da03317 100644 --- a/frontend/src/components/PlaylistsRail.tsx +++ b/frontend/src/components/PlaylistsRail.tsx @@ -9,7 +9,8 @@ import { compareText } from "../lib/collate"; import { formatRuntime } from "../lib/format"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; -import { LS, readAccountMerged, writeAccount } from "../lib/storage"; +import { LS } from "../lib/storage"; +import { usePref } from "../lib/usePref"; import { type PanelLayout } from "../lib/panelLayout"; import FilterRail from "./FilterRail"; import { Button } from "./ui/Button"; @@ -65,7 +66,10 @@ export default function PlaylistsRail({ const { t, i18n } = useTranslation(); const lang = i18n.language; const qc = useQueryClient(); - const [plSort, setPlSort] = useState(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT)); + const [plSort, setPlSort] = usePref("plSort", PL_SORT_DEFAULT, { + localKey: LS.plSort, + parse: (raw) => ({ ...PL_SORT_DEFAULT, ...(raw && typeof raw === "object" ? raw : {}) }), + }); const plSortOptions: SortOption[] = [ { key: "custom", label: t("playlists.railSortCustom"), directionless: true }, { key: "name", label: t("playlists.railSortName"), defaultDir: "asc" }, @@ -76,9 +80,6 @@ export default function PlaylistsRail({ // default: a rail that reshuffles itself while you look at it costs more than it saves. { key: "recent", label: t("playlists.railSortRecent"), defaultDir: "desc" }, ]; - useEffect(() => { - writeAccount(LS.plSort, plSort); - }, [plSort]); const [newName, setNewName] = useState(""); const [syncing, setSyncing] = useState(false); const plActions = usePlaylistActions(); diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index f096b172..37400c88 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useMemo, useRef, useState, type ReactNode } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; @@ -27,7 +27,8 @@ import { notifyActionError } from "../lib/actionErrors"; import { navItemAttrs, useListNav } from "../lib/listNav"; import { toggleWatchState } from "../lib/plexWatch"; import { formatRuntime } from "../lib/format"; -import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; +import { LS } from "../lib/storage"; +import { usePref } from "../lib/usePref"; import { useConfirm } from "./confirmContext"; import { Button } from "./ui/Button"; import { LoadingState, StateMessage } from "./ui/QueryState"; @@ -424,12 +425,10 @@ export default function PlexPlaylistView({ const [renaming, setRenaming] = useState(false); const { target: renameTarget, arm: armRenameReturn } = useReturnFocus(renaming); const [name, setName] = useState(""); - const [layout, setLayout] = useState(() => - getAccountRaw(LS.plexPlaylistLayout) === "tree" ? "tree" : "accordion", - ); - useEffect(() => { - setAccountRaw(LS.plexPlaylistLayout, layout); - }, [layout]); + const [layout, setLayout] = usePref("plexPlaylistLayout", "accordion", { + localKey: LS.plexPlaylistLayout, + parse: (raw) => (raw === "tree" ? "tree" : "accordion"), + }); // Show groups start collapsed (compact headers) so a long series doesn't force endless scrolling; // seasons start expanded once their show is opened. Both are just local view state. const [expandedShows, setExpandedShows] = useState>(new Set()); diff --git a/frontend/src/components/PrefsProvider.dom.test.tsx b/frontend/src/components/PrefsProvider.dom.test.tsx index 8cf49c95..4c5d6d5d 100644 --- a/frontend/src/components/PrefsProvider.dom.test.tsx +++ b/frontend/src/components/PrefsProvider.dom.test.tsx @@ -2,8 +2,9 @@ // // The Settings prefs are seeded from the account's server prefs in the `me` cache — BootGate mounts the // tree only once it is there. The adopt effect this replaced corrected them AFTER mounting; with it -// gone, a seed that read only this browser's copy would never show a performance mode or hints setting -// changed on another device. And the seed is not an edit: it must not be saved straight back. +// gone, a seed that read only this browser's copy would never show a hints setting changed on another +// device. And the seed is not an edit: it must not be saved straight back. Performance mode is the +// exception: it is this device's, so the server's word on it is not taken and a change is not sent. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, render } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -14,16 +15,31 @@ import { claimLocalData, LS } from "../lib/storage"; import { qk } from "../lib/queryKeys"; const seen: { perf: boolean; hints: boolean }[] = []; +const controls: ReturnType[] = []; function Probe() { - const { perf, hints } = usePrefs(); - seen.push({ perf, hints }); + const prefs = usePrefs(); + controls.push(prefs); + seen.push({ perf: prefs.perf, hints: prefs.hints }); return null; } +function mount(preferences: Record) { + const qc = new QueryClient(); + qc.setQueryData(qk.me(), { id: 5, preferences }); + render( + + + + + , + ); +} + beforeEach(() => { localStorage.clear(); claimLocalData(5); seen.length = 0; + controls.length = 0; vi.useFakeTimers(); }); afterEach(() => { @@ -34,24 +50,38 @@ afterEach(() => { describe("PrefsProvider seed", () => { it("renders the account's server prefs from the first render, and does not save them back", () => { - localStorage.setItem(LS.perfMode, "0"); // this browser last had it off localStorage.setItem(LS.hints, "1"); const save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never); - const qc = new QueryClient(); - qc.setQueryData(qk.me(), { id: 5, preferences: { performanceMode: true, hints: false } }); - render( - - - - - , - ); + mount({ hints: false }); - expect(seen[0]).toEqual({ perf: true, hints: false }); + expect(seen[0]?.hints).toBe(false); // past the auto-save debounce act(() => { vi.advanceTimersByTime(1000); }); expect(save).not.toHaveBeenCalled(); }); + + it("takes performance mode from this device, never from the account", () => { + localStorage.setItem(LS.perfMode, "1"); // this device has it on + mount({ performanceMode: false }); // an older build saved the account's value + expect(seen[0]?.perf).toBe(true); + cleanup(); + + localStorage.setItem(LS.perfMode, "0"); + mount({ performanceMode: true }); + expect(seen[seen.length - 1]?.perf).toBe(false); + }); + + it("keeps a performance mode change on this device: nothing is sent to the server", () => { + const save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never); + mount({}); + act(() => controls[controls.length - 1]?.setPerf(true)); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(localStorage.getItem(LS.perfMode)).toBe("1"); + expect(document.documentElement.dataset.perf).toBe("1"); + expect(save).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/components/PrefsProvider.tsx b/frontend/src/components/PrefsProvider.tsx index 839d2f9a..2b374c8d 100644 --- a/frontend/src/components/PrefsProvider.tsx +++ b/frontend/src/components/PrefsProvider.tsx @@ -25,13 +25,13 @@ import { PrefsContext } from "./prefsContext"; const PERF_KEY = LS.perfMode; -// The four Settings-page preferences. They apply locally for instant preview and auto-save (debounced) -// to the server — there is no draft or Save button. This provider owns them (lifted out of the App god +// The Settings page's account preferences. They apply locally for instant preview and auto-save +// (debounced) to the server — there is no draft or Save button. Performance mode is held here too but +// is this device's (a weak GPU wants it, a strong one does not), so it stays in this browser only. This provider owns them (lifted out of the App god // component) plus the last-saved "baseline" (the server values it was seeded with, then each successful // save) used to suppress saving what the server already holds. Consumers (SettingsPanel) read the PrefsController via usePrefs() (prefsContext.ts). type EditablePrefs = { theme: ThemePrefs; - performanceMode: boolean; hints: boolean; railLabels: boolean; notifications: NotifSettings; @@ -46,10 +46,6 @@ export function PrefsProvider({ children }: { children: ReactNode }) { const prefs = qc.getQueryData(qk.me())?.preferences; return { theme: prefs?.theme ? normalizeTheme(prefs.theme) : loadLocalTheme(), - performanceMode: - typeof prefs?.performanceMode === "boolean" - ? prefs.performanceMode - : getAccountRaw(PERF_KEY) === "1", hints: typeof prefs?.hints === "boolean" ? prefs.hints : hintsEnabled(), // B7: off by default — the icon rail with name tooltips is the house look. railLabels: prefs?.railLabels === true, @@ -59,7 +55,7 @@ export function PrefsProvider({ children }: { children: ReactNode }) { }; }); const [theme, setThemeState] = useState(seed.theme); - const [perf, setPerf] = useState(seed.performanceMode); + const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1"); const [hints, setHints] = useState(seed.hints); const [railLabels, setRailLabels] = useState(seed.railLabels); const [notif, setNotif] = useState(seed.notifications); @@ -92,8 +88,8 @@ export function PrefsProvider({ children }: { children: ReactNode }) { // effects above; this is the only persistence path. `baselineRef` keeps the mount seed from being // saved straight back (`/me` merges, so a full payload is fine). const editablePrefs = useMemo( - () => ({ theme, performanceMode: perf, hints, railLabels, notifications: notif }), - [theme, perf, hints, railLabels, notif], + () => ({ theme, hints, railLabels, notifications: notif }), + [theme, hints, railLabels, notif], ); useEffect(() => { window.clearTimeout(saveTimer.current); diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index a0ab887c..9af687f1 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -24,7 +24,7 @@ import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { api, HttpError, type Me } from "../lib/api"; import { displayName, roleKey } from "../lib/api/account"; import { notifyActionError } from "../lib/actionErrors"; -import { LS, useAccountPersistedState, useTabPersistedState } from "../lib/storage"; +import { LS, useTabPersistedState } from "../lib/storage"; import Avatar from "./ui/Avatar"; import { notify, type NotifSettings } from "../lib/notifications"; import Tooltip from "./ui/Tooltip"; @@ -35,7 +35,8 @@ import { type SaveState } from "./ui/DraftSaveBar"; import { useConfirm } from "./confirmContext"; import { StateMessage } from "./ui/QueryState"; import { useWizard } from "./wizardContext"; -import { START_ON_HOME } from "./navigationInit"; +import { START_ON_HOME_OPTIONS, START_ON_HOME_PREF } from "./navigationInit"; +import { usePref } from "../lib/usePref"; import { usePrefs } from "./prefsContext"; import Tabs from "./ui/Tabs"; import { useTabPanel } from "./ui/tabPanel"; @@ -164,10 +165,8 @@ function Appearance({ prefs }: { prefs: PrefsController }) { const { t } = useTranslation(); // Controlled by PrefsProvider: each change applies locally at once and auto-saves (debounced). const { theme, setTheme, perf, setPerf, hints, setHints, railLabels, setRailLabels } = prefs; - // The landing page. Per-account localStorage, not a server preference like the rest of this - // tab: `loadInitialPage` reads it synchronously in NavigationProvider's initializer. The two stored - // values come from navigationInit, which is what reads them back at boot. - const [startOnHome, setStartOnHome] = useAccountPersistedState(LS.startOnHome, START_ON_HOME.on); + // The landing page: read at boot (NavigationProvider), so a change applies from the next start. + const [startOnHome, setStartOnHome] = usePref(START_ON_HOME_PREF, true, START_ON_HOME_OPTIONS); return ( <> @@ -242,8 +241,8 @@ function Appearance({ prefs }: { prefs: PrefsController }) { setStartOnHome(v ? START_ON_HOME.on : START_ON_HOME.off)} + checked={startOnHome} + onChange={setStartOnHome} /> diff --git a/frontend/src/components/VersionBanner.dom.test.tsx b/frontend/src/components/VersionBanner.dom.test.tsx new file mode 100644 index 00000000..5ef8eff9 --- /dev/null +++ b/frontend/src/components/VersionBanner.dom.test.tsx @@ -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()), + 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; +function mount(preferences: Record) { + const qc = new QueryClient(); + qc.setQueryData(qk.me(), { id: 5, preferences }); + render( + + {}} /> + , + ); +} +const banner = () => screen.queryByText("header.banner.updated", { exact: false }); + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + 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" }); + }); +}); diff --git a/frontend/src/components/VersionBanner.tsx b/frontend/src/components/VersionBanner.tsx index 93843c5b..bcd31653 100644 --- a/frontend/src/components/VersionBanner.tsx +++ b/frontend/src/components/VersionBanner.tsx @@ -1,23 +1,19 @@ -import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Sparkles } from "lucide-react"; -import { FRONTEND_VERSION, SEEN_VERSION_KEY } from "../lib/version"; +import { FRONTEND_VERSION, SEEN_VERSION_OPTIONS, SEEN_VERSION_PREF } from "../lib/version"; +import { usePref } from "../lib/usePref"; import Banner from "./ui/Banner"; // Eye-catching, dismissible bar shown once after the running build's version changes // (compares the baked frontend version to the last one the user acknowledged). export default function VersionBanner({ onOpen }: { onOpen: () => void }) { const { t } = useTranslation(); - const [dismissed, setDismissed] = useState(false); - const seen = localStorage.getItem(SEEN_VERSION_KEY); + const [seen, setSeen] = usePref(SEEN_VERSION_PREF, null, SEEN_VERSION_OPTIONS); // Don't nag in un-stamped dev builds, or once this version has been seen. - const show = FRONTEND_VERSION !== "dev" && seen !== FRONTEND_VERSION && !dismissed; + const show = FRONTEND_VERSION !== "dev" && seen !== FRONTEND_VERSION; if (!show) return null; - function markSeen() { - localStorage.setItem(SEEN_VERSION_KEY, FRONTEND_VERSION); - setDismissed(true); - } + const markSeen = () => setSeen(FRONTEND_VERSION); return ( ({ + default: ({ onClose }: { onClose: (dismiss: boolean) => void }) => ( + + ), +})); + +import { WizardProvider } from "./WizardProvider"; +import { api } from "../lib/api"; +import { claimLocalData, LS } from "../lib/storage"; +import { qk } from "../lib/queryKeys"; + +let save: ReturnType; +function mount(preferences: Record) { + const qc = new QueryClient(); + qc.setQueryData(qk.me(), { + id: 5, + can_read: false, + can_write: false, + google_enabled: true, + preferences, + }); + render( + + {null} + , + ); + 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 }>(qk.me())?.preferences, + ).toMatchObject({ onboardingDismissed: true }); + expect(wizard()).toBeNull(); + }); +}); diff --git a/frontend/src/components/WizardProvider.tsx b/frontend/src/components/WizardProvider.tsx index 3f31841a..00c9045e 100644 --- a/frontend/src/components/WizardProvider.tsx +++ b/frontend/src/components/WizardProvider.tsx @@ -2,7 +2,12 @@ import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type ReactNo import { qk } from "../lib/queryKeys"; import { useQuery } from "@tanstack/react-query"; import { api } from "../lib/api"; -import { shouldAutoOpenOnboarding } from "../lib/onboarding"; +import { + ONBOARDING_DISMISSED_OPTIONS, + ONBOARDING_DISMISSED_PREF, + shouldAutoOpenOnboarding, +} from "../lib/onboarding"; +import { prefValue, usePref } from "../lib/usePref"; import { WizardActionsContext, type WizardActions } from "./wizardContext"; const OnboardingWizard = lazy(() => import("./OnboardingWizard")); @@ -23,8 +28,20 @@ export function WizardProvider({ children }: { children: ReactNode }) { // Passive observer of the account BootGate fetches (enabled:false → reads cache, re-renders on update). const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false }); + // The setter only: the check below reads the value from `me`, which the setter keeps current. + const [, setDismissed] = usePref(ONBOARDING_DISMISSED_PREF, false, ONBOARDING_DISMISSED_OPTIONS); + const dismissed = prefValue(me, ONBOARDING_DISMISSED_PREF, false, ONBOARDING_DISMISSED_OPTIONS); + const openWizard = useCallback(() => setOpen(true), []); const closeWizard = useCallback(() => setOpen(false), []); + // Leaving without the essential read step suppresses the auto-popup from then on. + const finishWizard = useCallback( + (dismiss: boolean) => { + if (dismiss) setDismissed(true); + setOpen(false); + }, + [setDismissed], + ); // First-login onboarding: prompt the user to connect YouTube (and resume the flow after each // consent redirect). Re-checked when the account or its grants change. @@ -39,8 +56,8 @@ export function WizardProvider({ children }: { children: ReactNode }) { if (meId == null || canRead == null) return; const account = { can_read: canRead, google_enabled: googleEnabled }; // eslint-disable-next-line react-hooks/set-state-in-effect -- opening is a one-way decision taken from SERVER data as it arrives, not a value derivable in render — deriving `open` from the grants would re-open a wizard the user had just closed, which is exactly what the narrowed dep list above exists to prevent. - if (shouldAutoOpenOnboarding(account)) setOpen(true); - }, [meId, canRead, canWrite, googleEnabled]); + if (shouldAutoOpenOnboarding(account, dismissed)) setOpen(true); + }, [meId, canRead, canWrite, googleEnabled, dismissed]); const actions = useMemo( () => ({ openWizard, closeWizard }), @@ -52,7 +69,7 @@ export function WizardProvider({ children }: { children: ReactNode }) { {children} {/* The wizard is a full-screen overlay. Lazy — its chunk loads only when first opened. */} - {open && me && } + {open && me && } ); diff --git a/frontend/src/components/navigation.dom.test.ts b/frontend/src/components/navigation.dom.test.ts index 5306a247..721adbe7 100644 --- a/frontend/src/components/navigation.dom.test.ts +++ b/frontend/src/components/navigation.dom.test.ts @@ -6,7 +6,8 @@ // • channelViewFrom — the shared _chan/?chan resolver behind BOTH the channelView seed and the // onPop handler: a popstate landing on a URL-only bookmark entry must restore the // channel from ?chan, not close it. -// Only getAccountRaw is stubbed (the account's stored landing page); everything else is real. +// Only getAccountRaw is stubbed (the page the account left open); everything else is real. Whether +// to start on Home is the account's preference, handed in. import { describe, it, expect, beforeEach, vi } from "vitest"; const { stored } = vi.hoisted(() => ({ stored: {} as Record })); @@ -15,87 +16,90 @@ vi.mock("../lib/storage", async (importOriginal) => { return { ...actual, getAccountRaw: (k: string) => stored[k] ?? null }; }); -import { loadInitialPage, channelViewFrom, START_ON_HOME } from "./navigationInit"; +import { loadInitialPage, channelViewFrom } from "./navigationInit"; import { LS } from "../lib/storage"; const setUrl = (search: string) => window.history.replaceState(null, "", search || "/"); +let startOnHome = true; + beforeEach(() => { setUrl("/"); + startOnHome = true; for (const k of Object.keys(stored)) delete stored[k]; }); describe("loadInitialPage precedence", () => { it("?page wins and is validated", () => { setUrl("?page=stats"); - expect(loadInitialPage()).toBe("stats"); + expect(loadInitialPage(startOnHome)).toBe("stats"); }); it("an invalid ?page falls back to feed", () => { setUrl("?page=bogus"); - expect(loadInitialPage()).toBe("feed"); + expect(loadInitialPage(startOnHome)).toBe("feed"); }); it("?v implies the plex module", () => { setUrl("?v=show:1"); - expect(loadInitialPage()).toBe("plex"); + expect(loadInitialPage(startOnHome)).toBe("plex"); }); it("?pl implies playlists", () => { setUrl("?pl=3"); - expect(loadInitialPage()).toBe("playlists"); + expect(loadInitialPage(startOnHome)).toBe("playlists"); }); it.each(["?folder=4", "?item=12", "?folder=all&item=12"])( "%s implies the Files page (a folder, or a file to arrive at)", (search) => { setUrl(search); - expect(loadInitialPage()).toBe("files"); + expect(loadInitialPage(startOnHome)).toBe("files"); }, ); it("a filter param implies the feed", () => { setUrl("?tags=1,2"); - expect(loadInitialPage()).toBe("feed"); + expect(loadInitialPage(startOnHome)).toBe("feed"); }); it("?chan (the channel overlay) implies the feed", () => { setUrl("?chan=UC1"); - expect(loadInitialPage()).toBe("feed"); + expect(loadInitialPage(startOnHome)).toBe("feed"); }); it("?page beats a lower-precedence param", () => { setUrl("?page=stats&v=show:1"); - expect(loadInitialPage()).toBe("stats"); + expect(loadInitialPage(startOnHome)).toBe("stats"); }); it("?v beats ?pl and filters", () => { setUrl("?v=show:1&pl=3&tags=1"); - expect(loadInitialPage()).toBe("plex"); + expect(loadInitialPage(startOnHome)).toBe("plex"); }); // A bare URL lands on Home, OVER the remembered module. Landing on // whatever you happened to close the app on is precisely the habit the anti-paralysis surface // exists to replace, so the remembered page only wins once the account opts out. it("a bare URL lands on Home even when another module is remembered", () => { stored[LS.page] = "plex"; - expect(loadInitialPage()).toBe("home"); + expect(loadInitialPage(startOnHome)).toBe("home"); }); it("opting out restores the account's stored landing page", () => { - stored[LS.startOnHome] = START_ON_HOME.off; + startOnHome = false; stored[LS.page] = "plex"; - expect(loadInitialPage()).toBe("plex"); + expect(loadInitialPage(startOnHome)).toBe("plex"); }); // The Downloads page is gone; what it held is in Files. A bookmark and a remembered page both land // there instead of on the feed. it("a retired page lands where its content went — linked or remembered", () => { setUrl("?page=downloads"); - expect(loadInitialPage()).toBe("files"); + expect(loadInitialPage(startOnHome)).toBe("files"); setUrl("/"); - stored[LS.startOnHome] = START_ON_HOME.off; + startOnHome = false; stored[LS.page] = "downloads"; - expect(loadInitialPage()).toBe("files"); + expect(loadInitialPage(startOnHome)).toBe("files"); }); it("opted out with no / invalid stored page still defaults to feed", () => { - stored[LS.startOnHome] = START_ON_HOME.off; - expect(loadInitialPage()).toBe("feed"); + startOnHome = false; + expect(loadInitialPage(startOnHome)).toBe("feed"); stored[LS.page] = "nope"; - expect(loadInitialPage()).toBe("feed"); + expect(loadInitialPage(startOnHome)).toBe("feed"); }); it("a deep link still beats Home", () => { setUrl("?pl=3"); - expect(loadInitialPage()).toBe("playlists"); + expect(loadInitialPage(startOnHome)).toBe("playlists"); }); }); diff --git a/frontend/src/components/navigationInit.ts b/frontend/src/components/navigationInit.ts index 2db279ca..d820940e 100644 --- a/frontend/src/components/navigationInit.ts +++ b/frontend/src/components/navigationInit.ts @@ -4,24 +4,25 @@ // channelView seed and the onPop handler. import { hasFilterParams, readPage, resolvePage, type Page } from "../lib/urlState"; import { getAccountRaw, LS } from "../lib/storage"; +import type { UsePrefOptions } from "../lib/usePref"; export const PAGE_KEY = LS.page; -// Module-local: the Settings toggle writes `LS.startOnHome` directly, so exporting this alias would -// only be a second name for the same key. -const START_HOME_KEY = LS.startOnHome; - -/** The two stored values of the landing preference (raw strings, via useAccountPersistedState). - * Settings → Appearance → Startup writes them and `loadInitialPage` reads them, so they are defined - * ONCE: the same magic string spelled out in a writer and a reader is precisely the drift that has - * bitten this codebase before (`cancelled` vs `canceled`). */ -export const START_ON_HOME = { on: "1", off: "0" } as const; +/** The landing preference — start on Home, or on the module left open — an account preference + * (usePref). Settings → Appearance → Startup writes it and NavigationProvider reads it for + * `loadInitialPage`, so its key and its reading are defined ONCE. A copy this browser kept before + * the server held it is the string "0" (off) or "1". */ +export const START_ON_HOME_PREF = "startOnHome"; +export const START_ON_HOME_OPTIONS: UsePrefOptions = { + localKey: LS.startOnHome, + parse: (raw) => raw !== false && raw !== "0" && raw !== 0, +}; export interface ChannelView { id: string; name?: string; } -export function loadInitialPage(): Page { +export function loadInitialPage(startOnHome: boolean): Page { const params = new URLSearchParams(window.location.search); if (params.has("page")) return readPage(); // A view-specific param implies its page, so a bookmarked deep link lands on the right module even @@ -35,7 +36,7 @@ export function loadInitialPage(): Page { // on whatever you happened to close the app on is the behaviour the anti-paralysis epic exists to // replace. The account can opt out (Settings → Appearance → Startup) and get the remembered // module back. - if (getAccountRaw(START_HOME_KEY) !== START_ON_HOME.off) return "home"; + if (startOnHome) return "home"; return resolvePage(getAccountRaw(PAGE_KEY)) ?? "feed"; } diff --git a/frontend/src/lib/debugRecorder.ts b/frontend/src/lib/debugRecorder.ts index f4efd5ee..f2c7c3fb 100644 --- a/frontend/src/lib/debugRecorder.ts +++ b/frontend/src/lib/debugRecorder.ts @@ -1,3 +1,4 @@ +import { LS } from "./storage"; import { createStore } from "./store"; // 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 // 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. -const STEP_KEY = "siftlode.debugSteps"; +const STEP_KEY = LS.debugSteps; function readSteps(): Record { try { diff --git a/frontend/src/lib/log.ts b/frontend/src/lib/log.ts index ce8d1b0a..7e67262c 100644 --- a/frontend/src/lib/log.ts +++ b/frontend/src/lib/log.ts @@ -17,6 +17,7 @@ // // 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. +import { LS } from "./storage"; import { FRONTEND_VERSION } from "./version"; 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 // 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 { try { const known = sessionStorage.getItem(TAB_KEY); diff --git a/frontend/src/lib/onboarding.ts b/frontend/src/lib/onboarding.ts index 67993533..a680516b 100644 --- a/frontend/src/lib/onboarding.ts +++ b/frontend/src/lib/onboarding.ts @@ -8,27 +8,32 @@ // ONBOARD_ACTIVE (sessionStorage) — set while the user is mid-flow (e.g. just clicked // "Connect"); makes the wizard reopen after the // redirect so they land on the next step. -// ONBOARD_DISMISSED (localStorage) — set when the user skips the essential read step; -// suppresses the auto-popup on future logins (they -// can still reopen it from Settings → Account). +// ONBOARDING_DISMISSED_PREF (an — set when the user skips the essential read step; +// account preference, usePref) suppresses the auto-popup on future logins, on every +// device (they can still reopen it from Settings → Account). 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) -// The account's (see ownKey): a fresh account should still get the onboarding nudge even if a -// different account in the same browser dismissed it. -const ONBOARD_DISMISSED = LS.onboardingDismissed; +const ONBOARD_ACTIVE = LS.onboardingActive; // sessionStorage, this tab only +// The account's, so a fresh account still gets the nudge where another one dismissed it. A copy this +// browser kept before the server held it is the string "1". +export const ONBOARDING_DISMISSED_PREF = "onboardingDismissed"; +export const ONBOARDING_DISMISSED_OPTIONS: UsePrefOptions = { + localKey: LS.onboardingDismissed, + parse: (raw) => raw === true || raw === "1" || raw === 1, +}; -export function shouldAutoOpenOnboarding(me: { - can_read: boolean; - google_enabled?: boolean; -}): boolean { +export function shouldAutoOpenOnboarding( + me: { can_read: boolean; google_enabled?: boolean }, + dismissed: boolean, +): boolean { // 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. if (me.google_enabled === false) return false; 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. */ @@ -37,8 +42,7 @@ export function beginGrant(access: "read" | "write"): void { connectGoogle(access); } -/** Leave the wizard. `dismiss` suppresses the future auto-popup (used when read is skipped). */ -export function endOnboarding(dismiss: boolean): void { +/** Leave the wizard's flow (its dismissal, if any, is the caller's preference to set). */ +export function endOnboarding(): void { sessionStorage.removeItem(ONBOARD_ACTIVE); - if (dismiss) setAccountRaw(ONBOARD_DISMISSED, "1"); } diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 60df512f..d6fc89e9 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -1,100 +1,129 @@ import { useCallback, useState } from "react"; -// Central registry of every localStorage key the app uses. One place to see them all, rename -// safely, and avoid collisions — instead of `"siftlode.x"` string literals scattered across -// files. Parametric (per-user) keys are functions. All keys are namespaced `siftlode.*`. -export const LS = { - theme: "siftlode.theme", - sidebarLayout: "siftlode.sidebarLayout", - plexLayout: "siftlode.plexLayout", - playlistsLayout: "siftlode.playlistsLayout", - playlistsCollapsed: "siftlode.playlistsCollapsed", - filesLayout: "siftlode.filesLayout", - filesCollapsed: "siftlode.filesCollapsed", +// Central registry of every key the app keeps in the browser (localStorage, and a tab's +// sessionStorage). One place to see them all, rename safely, and avoid collisions — instead of +// `"siftlode.x"` string literals scattered across files. All keys are namespaced `siftlode.*`, and each +// declares its kind: what it is decides where it belongs, and a contract test holds the rule per kind +// (storageKinds.contract.test.ts). +// pref a deliberate choice about the account (theme, a list's layout): it lives on the server and +// follows the account to every device — usePref, whose local copy only carries a value +// this browser held before the server did. +// device right for this screen, speaker or GPU and wrong on another (a panel width, the volume, +// performance mode): this browser only. +// 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. - filesView: "siftlode.filesView", + filesView: { key: "siftlode.filesView", kind: "view" }, // 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 // grid's cards per page — per-account mirrors of the server prefs (the last is local only). - filesLayouts: "siftlode.filesLayouts", - filesSort: "siftlode.filesSort", - filesCardSize: "siftlode.filesCardSize", + filesLayouts: { key: "siftlode.filesLayouts", kind: "pref" }, + filesSort: { key: "siftlode.filesSort", kind: "pref" }, + filesCardSize: { key: "siftlode.filesCardSize", kind: "device" }, // 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 // 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 // server pref. - downloadProfile: "siftlode.downloadProfile", - hints: "siftlode.hints", - lang: "siftlode.lang", - filters: "siftlode.filters", - page: "siftlode.page", + downloadProfile: { key: "siftlode.downloadProfile", kind: "pref" }, + hints: { key: "siftlode.hints", kind: "pref" }, + lang: { key: "siftlode.lang", kind: "pref" }, + filters: { key: "siftlode.filters", kind: "view" }, + page: { key: "siftlode.page", kind: "view" }, // 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 // `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 // corrected after. - startOnHome: "siftlode.startOnHome", - perfMode: "siftlode.perfMode", + startOnHome: { key: "siftlode.startOnHome", kind: "pref" }, + 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 // 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 // chips, the Sync column funnel, and the card layout all share ONE state (per-account). Supersedes // 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 // `channelsFilterMap`, then inert. Do not write. - legacyChannelFilter: "siftlode.channelFilter", - channelsView: "siftlode.channelsView", + legacyChannelFilter: { key: "siftlode.channelFilter", kind: "view" }, + channelsView: { key: "siftlode.channelsView", kind: "view" }, // 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). - channelSort: "siftlode.channelSort", - channelShow: "siftlode.channelShow", - channelDiscoverySort: "siftlode.channelDiscoverySort", + channelSort: { key: "siftlode.channelSort", kind: "view" }, + channelShow: { key: "siftlode.channelShow", kind: "pref" }, + 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). - channelCardSize: "siftlode.channelCardSize", - channelDiscoveryCardSize: "siftlode.channelDiscoveryCardSize", - channelsTable: "siftlode.channelsTable", + channelCardSize: { key: "siftlode.channelCardSize", kind: "device" }, + channelDiscoveryCardSize: { key: "siftlode.channelDiscoveryCardSize", kind: "device" }, + channelsTable: { key: "siftlode.channelsTable", kind: "device" }, // 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. - channelNames: "siftlode.channelNames", - channelDiscoveryTable: "siftlode.channelDiscoveryTable", + channelNames: { key: "siftlode.channelNames", kind: "cache" }, + channelDiscoveryTable: { key: "siftlode.channelDiscoveryTable", kind: "device" }, // A page's sub-tab: sessionStorage, per browser tab (useTabPersistedState), like adminUsersTab // below. - settingsTab: "siftlode.settingsTab", - configTab: "siftlode.configTab", - adminUsersTab: "siftlode.adminUsersTab", - filterCollapsed: "siftlode.filterCollapsed", - playlist: "siftlode.playlist", - plSort: "siftlode.plSort", - plexLibrary: "siftlode.plexLibrary", - plexShow: "siftlode.plexShow", - plexSort: "siftlode.plexSort", - plexFilters: "siftlode.plexFilters", - plexQ: "siftlode.plexQ", - plexField: "siftlode.plexField", - plexPlaylistLayout: "siftlode.plexPlaylistLayout", - plexPlayerPrefs: "siftlode.plexPlayerPrefs", + settingsTab: { key: "siftlode.settingsTab", kind: "view" }, + configTab: { key: "siftlode.configTab", kind: "view" }, + adminUsersTab: { key: "siftlode.adminUsersTab", kind: "view" }, + filterCollapsed: { key: "siftlode.filterCollapsed", kind: "pref" }, + playlist: { key: "siftlode.playlist", kind: "view" }, + plSort: { key: "siftlode.plSort", kind: "pref" }, + plexLibrary: { key: "siftlode.plexLibrary", kind: "view" }, + plexShow: { key: "siftlode.plexShow", kind: "view" }, + plexSort: { key: "siftlode.plexSort", kind: "view" }, + plexFilters: { key: "siftlode.plexFilters", kind: "view" }, + plexQ: { key: "siftlode.plexQ", kind: "view" }, + plexField: { key: "siftlode.plexField", kind: "view" }, + plexPlaylistLayout: { key: "siftlode.plexPlaylistLayout", kind: "pref" }, + plexPlayerPrefs: { key: "siftlode.plexPlayerPrefs", kind: "device" }, // 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 // every next video is loud again (same idea as plexPlayerPrefs). - ytPlayerPrefs: "siftlode.ytPlayerPrefs", - notifHistory: "siftlode.notifications", - notifSettings: "siftlode.notifSettings", - onboardingDismissed: "siftlode.onboarding.dismissed", - seenVersion: "siftlode.seenVersion", + ytPlayerPrefs: { key: "siftlode.ytPlayerPrefs", kind: "device" }, + notifHistory: { key: "siftlode.notifications", kind: "cache" }, + notifSettings: { key: "siftlode.notifSettings", kind: "pref" }, + onboardingDismissed: { key: "siftlode.onboarding.dismissed", kind: "pref" }, + seenVersion: { key: "siftlode.seenVersion", kind: "pref" }, // 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. - 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 // reason as above: the viewer has no account to keep it in. - watchResume: "siftlode.watchResume", -} as const; + watchResume: { key: "siftlode.watchResume", kind: "public" }, + // 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; + +/** 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 = new Map( + Object.values(KEYS).map(({ key, kind }) => [key, kind]), +); // --- 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 @@ -105,12 +134,15 @@ export const LS = { const OWNER_KEY = "siftlode.owner"; // Kept whoever signs in: they belong to the browser, not to an account (the public /watch page has none). -const BROWSER_KEYS: ReadonlySet = new Set([LS.watchRepeat, LS.watchResume, OWNER_KEY]); +const BROWSER_KEYS: ReadonlySet = 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 // 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. const LEGACY_PIN_KEYS = ["siftlode.activeAccount", "siftlode.nextAccount"]; -const REGISTERED: ReadonlySet = new Set(Object.values(LS)); +const REGISTERED: ReadonlySet = new Set(KIND_OF.keys()); const ACCOUNT_COPY = /^(.+)\.(\d+)$/; let claimed: string | null = null; diff --git a/frontend/src/lib/storageKinds.contract.test.ts b/frontend/src/lib/storageKinds.contract.test.ts new file mode 100644 index 00000000..324f760f --- /dev/null +++ b/frontend/src/lib/storageKinds.contract.test.ts @@ -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 = { + 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([]); + }); +}); diff --git a/frontend/src/lib/usePref.dom.test.ts b/frontend/src/lib/usePref.dom.test.ts index 072e9a55..00095f3e 100644 --- a/frontend/src/lib/usePref.dom.test.ts +++ b/frontend/src/lib/usePref.dom.test.ts @@ -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`. vi.mock("./storage", async (importOriginal) => ({ ...(await importOriginal()), - readAccount: (base: string, fallback: unknown) => { - const raw = store.get(base); - return raw == null ? fallback : JSON.parse(raw); - }, + getAccountRaw: (base: string) => store.get(base) ?? null, writeAccount: (base: string, value: unknown) => { store.set(base, JSON.stringify(value)); }, @@ -77,6 +74,52 @@ describe("usePref", () => { 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("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("view", "cards", { localKey: "siftlode.feedView" }); + renderHook(useViewPref, { wrapper: wrapperFor(qc) }); + expect(savePrefs).toHaveBeenCalledExactlyOnceWith({ view: "tiles" }); + expect(qc.getQueryData<{ preferences: Record }>(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("view", "cards", { localKey: "siftlode.feedView" }), { + wrapper: wrapperFor(qc), + }); + store.clear(); + renderHook(() => usePref("other", "x", { localKey: "siftlode.feedView" }), { + wrapper: wrapperFor(qc), + }); + expect(savePrefs).not.toHaveBeenCalled(); + }); + it("prefers the server value over the mirror", () => { const qc = makeClient(); seedMe(qc, { view: "rows" }); diff --git a/frontend/src/lib/usePref.ts b/frontend/src/lib/usePref.ts index b00fbba2..5afc5941 100644 --- a/frontend/src/lib/usePref.ts +++ b/frontend/src/lib/usePref.ts @@ -3,7 +3,7 @@ import { useQueryClient, type QueryClient } from "@tanstack/react-query"; import { qk } from "./queryKeys"; import { api } from "./api"; import type { Me } from "./api/account"; -import { readAccount, writeAccount } from "./storage"; +import { getAccountRaw, writeAccount } from "./storage"; import { notifyActionError } from "./actionErrors"; import { updateMeCache } from "./useMe"; 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. export interface UsePrefOptions { - /** Also mirror to per-account localStorage under this base key; that copy seeds the value when the - * server payload lacks the key. */ + /** Also mirror to the account's localStorage under this base key; that copy seeds the value (and + * the server, once) when the server payload lacks the key. */ localKey?: string; /** 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. */ @@ -41,6 +41,45 @@ function readServerPref(me: Me | null | undefined, key: string): unknown { 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( + me: Me | null | undefined, + serverKey: string, + defaultValue: T, + { localKey, parse }: UsePrefOptions, +): { 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( + me: Me | null | undefined, + serverKey: string, + defaultValue: T, + options: UsePrefOptions = {}, +): T { + return resolvePref(me, serverKey, defaultValue, options).value; +} + // 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 // (their sole difference is WHEN the server write fires — immediately vs debounced), so the cache-merge @@ -71,21 +110,23 @@ export function usePref( defaultValue: T, options: UsePrefOptions = {}, ): [T, (next: T) => void] { - const { localKey, debounceMs, parse } = options; + const { localKey, debounceMs } = options; const qc = useQueryClient(); - // Seeded inline rather than through a memoized `seed()`: useState calls a lazy initializer exactly - // once, at mount, so there was nothing for the useCallback to save — and its dep list was the one - // place in this file that had to be excused from the checker (it read `defaultValue` without - // naming it). Server preference wins, then the per-account localStorage mirror, then the default. - const [value, setValue] = useState(() => { - // `parse` is read here only, once: the setter stores what it is given. - const norm = (raw: unknown): T => (parse ? parse(raw) : (raw as T)); - const server = readServerPref(qc.getQueryData(qk.me()), serverKey); - if (server !== undefined) return norm(server); - if (localKey) return norm(readAccount(localKey, defaultValue)); - return defaultValue; - }); + // Read once, at mount (`parse` is read here only: the setter stores what it is given). Server + // preference wins, then this browser's copy, then the default. + const [seed] = useState(() => + resolvePref(qc.getQueryData(qk.me()), serverKey, defaultValue, options), + ); + const [value, setValue] = useState(seed.value); + // A value only this browser held (the server has never stored the key) is saved to the server once, + // so the account's other devices get it too; from then on the server's wins everywhere. + useEffect(() => { + if (!seed.fromLocal) return; + const payload = { [serverKey]: seed.value }; + mergePrefsCache(qc, payload); + api.savePrefs(payload).catch(reportPrefSaveFailure); + }, [seed, serverKey, qc]); const saveTimer = useRef(undefined); // The pending debounced server write, if any — flushed (NOT dropped) on unmount so the last change diff --git a/frontend/src/lib/version.ts b/frontend/src/lib/version.ts index e7ee5577..399a4c08 100644 --- a/frontend/src/lib/version.ts +++ b/frontend/src/lib/version.ts @@ -1,10 +1,16 @@ // 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. import { LS } from "./storage"; +import type { UsePrefOptions } from "./usePref"; 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_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || ""; -// Key for the "last version the user has seen release notes for" (drives the banner). -export const SEEN_VERSION_KEY = LS.seenVersion; +// The last version the account has seen the release notes banner for — an account preference +// (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 = { + localKey: LS.seenVersion, + parse: (raw) => (raw == null ? null : String(raw)), +};