refactor(frontend): one account per browser, its local state under plain keys
The per-tab account pin, the X-Siftlode-Account header and ?account= go: the session cookie alone says who is asking. localStorage keys lose their account suffix; the browser records whose state it holds (siftlode.owner), BootGate hands it to the account the server answered as, and a different account starts from a clean slate (the public /watch keys stay). The first boot moves the account's old per-account copies onto the plain keys and drops other accounts' copies. Until the claim, and after another tab claimed the browser for someone else, the account helpers read and write nothing. The account panel lists no other accounts: "Add another account" becomes "Sign in as someone else" (Google's account chooser; the pick replaces this account, a cancel keeps it), shown where Google sign-in is configured.
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// BootGate settles the tab's identity BEFORE the app tree mounts. The bug it replaced: a new tab (no
|
||||
// pinned account in sessionStorage) built the app for "no account" first — so with Settings →
|
||||
// Appearance → Startup turned off, NavigationProvider's initializer could not read the opt-out and
|
||||
// landed on Home, mirrored ?page=home into the address bar, and then re-read its own mirror when the
|
||||
// account was pinned, staying on Home. Only a same-tab reload (account already pinned) was right.
|
||||
// BootGate settles the tab's identity BEFORE the app tree mounts. The bug it replaced: a new tab built
|
||||
// the app for "no account" first — so with Settings → Appearance → Startup turned off,
|
||||
// NavigationProvider's initializer could not read the opt-out and landed on Home, mirrored ?page=home
|
||||
// into the address bar, and then re-read its own mirror once the account was known, staying on Home.
|
||||
//
|
||||
// Real NavigationProvider, real storage and i18n; only the two boot probes are stubbed. The probe
|
||||
// records EVERY render, so "never showed the wrong page, not even for one render" is what is asserted.
|
||||
@@ -17,7 +16,6 @@ import BootGate from "./BootGate";
|
||||
import { NavigationProvider } from "./NavigationProvider";
|
||||
import { useNavigation } from "./navigationContext";
|
||||
import { api, type Me } from "../lib/api";
|
||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import i18n from "../i18n";
|
||||
|
||||
@@ -53,10 +51,17 @@ const account = (over: Partial<Me> = {}) =>
|
||||
({ id: 5, preferences: {}, plex_enabled: true, ...over }) as Me;
|
||||
let savePrefs: MockInstance<typeof api.savePrefs>;
|
||||
const retryButton = () => screen.findByRole("button", { name: i18n.t("common.retry") });
|
||||
// Whose local state the browser holds (storage.ts claimLocalData).
|
||||
const owner = () => localStorage.getItem("siftlode.owner");
|
||||
// The browser already holds account 5's state, as on any visit after the first.
|
||||
const heldBy5 = (entries: Record<string, string>) => {
|
||||
localStorage.setItem("siftlode.owner", "5");
|
||||
for (const [k, v] of Object.entries(entries)) localStorage.setItem(k, v);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear(); // a NEW tab: nothing pinned yet
|
||||
sessionStorage.clear(); // a NEW tab
|
||||
window.history.replaceState(null, "", "/");
|
||||
renders.length = 0;
|
||||
mounts = 0;
|
||||
@@ -72,15 +77,14 @@ afterEach(() => {
|
||||
|
||||
describe("BootGate", () => {
|
||||
it("a new tab with Startup off opens on the page the account left — never on Home first", async () => {
|
||||
localStorage.setItem("siftlode.startOnHome.5", "0");
|
||||
localStorage.setItem("siftlode.page.5", "plex");
|
||||
heldBy5({ "siftlode.startOnHome": "0", "siftlode.page": "plex" });
|
||||
vi.spyOn(api, "me").mockResolvedValue(account());
|
||||
mount();
|
||||
|
||||
expect((await screen.findByTestId("app")).textContent).toBe("plex");
|
||||
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "plex"));
|
||||
expect(new URLSearchParams(window.location.search).get("page")).toBe("plex");
|
||||
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); // the tab is pinned
|
||||
expect(owner()).toBe("5");
|
||||
});
|
||||
|
||||
it("renders the app in the account's language from its first render", async () => {
|
||||
@@ -120,12 +124,12 @@ describe("BootGate", () => {
|
||||
expect(renders).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("signed out: mounts the app (App shows the landing) without pinning an account", async () => {
|
||||
it("signed out: mounts the app (App shows the landing) without claiming the local state", async () => {
|
||||
vi.spyOn(api, "me").mockResolvedValue(null);
|
||||
mount();
|
||||
|
||||
await screen.findByTestId("app");
|
||||
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBeNull();
|
||||
expect(owner()).toBeNull();
|
||||
});
|
||||
|
||||
it("setup mode: mounts the app (App shows the wizard) without asking for the account", async () => {
|
||||
@@ -135,7 +139,7 @@ describe("BootGate", () => {
|
||||
|
||||
await screen.findByTestId("app");
|
||||
expect(me).not.toHaveBeenCalled();
|
||||
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBeNull();
|
||||
expect(owner()).toBeNull();
|
||||
});
|
||||
|
||||
it("an unanswered setup probe is an error with a retry — never the signed-out landing", async () => {
|
||||
@@ -148,7 +152,7 @@ describe("BootGate", () => {
|
||||
fireEvent.click(await retryButton());
|
||||
await screen.findByTestId("app");
|
||||
expect(renders.length).toBeGreaterThan(0);
|
||||
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); // it mounted for the account
|
||||
expect(owner()).toBe("5"); // it mounted for the account
|
||||
});
|
||||
|
||||
it("an unanswered first account probe is an error with a retry", async () => {
|
||||
@@ -157,7 +161,7 @@ describe("BootGate", () => {
|
||||
|
||||
fireEvent.click(await retryButton());
|
||||
await screen.findByTestId("app");
|
||||
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
|
||||
expect(owner()).toBe("5");
|
||||
});
|
||||
|
||||
it("a background refetch that fails keeps the app mounted, as it was", async () => {
|
||||
@@ -175,17 +179,30 @@ describe("BootGate", () => {
|
||||
expect(mounts).toBe(1);
|
||||
});
|
||||
|
||||
it("a pin naming another account than the server answered as is corrected before the app reads it", async () => {
|
||||
// Pinned to 9, but 9 was signed out in another tab: the server answers as the browser's default, 5.
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "9");
|
||||
it("an account signing in where another one's state is held never reads it, not for one render", async () => {
|
||||
// Account 9 used this browser last, and left it on Plex with Startup off.
|
||||
localStorage.setItem("siftlode.owner", "9");
|
||||
localStorage.setItem("siftlode.startOnHome", "0");
|
||||
localStorage.setItem("siftlode.page", "plex");
|
||||
vi.spyOn(api, "me").mockResolvedValue(account());
|
||||
mount();
|
||||
|
||||
expect((await screen.findByTestId("app")).textContent).toBe("home");
|
||||
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "home"));
|
||||
expect(owner()).toBe("5");
|
||||
expect(localStorage.getItem("siftlode.page")).not.toBe("plex");
|
||||
});
|
||||
|
||||
it("an account's state left under the old per-account keys is the one it boots on", async () => {
|
||||
localStorage.setItem("siftlode.startOnHome.5", "0");
|
||||
localStorage.setItem("siftlode.page.5", "plex");
|
||||
localStorage.setItem("siftlode.page.9", "files");
|
||||
vi.spyOn(api, "me").mockResolvedValue(account());
|
||||
mount();
|
||||
|
||||
expect((await screen.findByTestId("app")).textContent).toBe("plex");
|
||||
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "plex"));
|
||||
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
|
||||
expect(localStorage.getItem("siftlode.page.9")).toBeNull();
|
||||
});
|
||||
|
||||
it("a mounted tab whose identity changes reloads rather than keep the previous account's tree", async () => {
|
||||
@@ -201,20 +218,20 @@ describe("BootGate", () => {
|
||||
await waitFor(() => expect(reload).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("an identity change reloads without moving the pin first", async () => {
|
||||
// The tree being torn down still runs its unmount saves before the page goes; with the pin already
|
||||
// moved they would land under the NEW account's local keys (a player volume still pending, say).
|
||||
it("an identity change reloads without handing the local state over first", async () => {
|
||||
// The tree being torn down still runs its unmount saves before the page goes; with the state
|
||||
// already handed over they would land in the NEW account's (a player volume still pending, say).
|
||||
const me = vi.spyOn(api, "me").mockResolvedValue(account());
|
||||
const qc = mount();
|
||||
await screen.findByTestId("app");
|
||||
const reload = vi.fn();
|
||||
vi.stubGlobal("location", { ...window.location, reload });
|
||||
|
||||
me.mockResolvedValue(account({ id: 6 })); // 5 was signed out elsewhere: the server answers as 6
|
||||
me.mockResolvedValue(account({ id: 6 })); // another tab signed this browser in as 6
|
||||
await act(() => qc.refetchQueries({ queryKey: qk.me() }));
|
||||
|
||||
await waitFor(() => expect(reload).toHaveBeenCalled());
|
||||
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
|
||||
expect(owner()).toBe("5");
|
||||
});
|
||||
|
||||
// The adoption effect is idempotent for the identity it already adopted, and that is load-bearing
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffectEvent, useLayoutEffect, useState, type ReactNode } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, getActiveAccount, setActiveAccount } from "../lib/api";
|
||||
import { api } from "../lib/api";
|
||||
import { log } from "../lib/log";
|
||||
import { claimLocalData } from "../lib/storage";
|
||||
import { saveLanguage } from "../lib/saveLanguage";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import i18n, { isSupported, setLanguage } from "../i18n";
|
||||
@@ -14,17 +15,16 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
// tab, the theme, the language — and all of it is read synchronously when its provider mounts. Those
|
||||
// providers used to mount before /api/me answered, so a new tab first built the app for "no account"
|
||||
// (defaults: Home instead of the page you left, the default theme, the browser language) and then
|
||||
// patched each piece once <App> pinned the account. Every patch was a visible jump at best, and at
|
||||
// patched each piece once <App> knew the account. Every patch was a visible jump at best, and at
|
||||
// worst it read a value the first pass had already written (the landing page mirrored ?page=home into
|
||||
// the address bar, then re-read its own mirror and stayed on Home). So the order is inverted instead:
|
||||
// settle the identity, adopt what must be adopted before a single pixel of the app is drawn, THEN mount
|
||||
// the provider tree — whose initializers are then simply right, with nothing to re-read.
|
||||
//
|
||||
// The per-account initializers read the tab's PIN (sessionStorage), not `me`, so the pin is made to
|
||||
// match the account the server answered as before they run. And the tree is only ever built for one
|
||||
// identity: if a mounted tab's identity changes without a reload (its session ended, or the account it
|
||||
// was pinned to was signed out in another tab and the server now answers as the browser's default),
|
||||
// the tab reloads, like every account switch does (NavSidebar) — which also drops the previous
|
||||
// The per-account initializers read the browser's local state, which holds one account's: it is handed
|
||||
// to the account the server answered as (claimLocalData) before they run. And the tree is only ever
|
||||
// built for one identity: if a mounted tab's identity changes without a reload (its session ended, or
|
||||
// another tab signed this browser in as someone else), the tab reloads — which also drops the previous
|
||||
// account's query cache, whose keys are not per account.
|
||||
|
||||
/** The quiet screen shown while the tab's identity (or the app chunk) is still on its way — the shared
|
||||
@@ -76,13 +76,10 @@ export default function BootGate({ children }: { children: ReactNode }) {
|
||||
: (me?.id ?? null);
|
||||
|
||||
// Adopt the identity before the tree mounts. A layout effect, so the re-render it triggers happens
|
||||
// before the browser paints: the frame that first shows the app already has the account pinned.
|
||||
// • Pin this tab to the account the server answered as. Without the pin, a tab riding the
|
||||
// session's default account would silently swap identity when ANOTHER tab changes that default
|
||||
// (adds or switches an account); once pinned it sends its own account header. The server honours
|
||||
// that header only for an account still signed in to this browser, so a pin naming one that is
|
||||
// not is corrected here — otherwise the tab would run as the default account on the server while
|
||||
// reading and writing the gone account's local state.
|
||||
// before the browser paints: the frame that first shows the app already reads the account's state.
|
||||
// • Hand the browser's local state to the account the server answered as: a different account
|
||||
// than the one whose state the browser holds starts from a clean slate, so it never reads (or
|
||||
// writes over) the previous one's filters, history or positions.
|
||||
// • Adopt the account's language, which follows the account across devices — the local copy only
|
||||
// knows what this browser last showed. An account with none stored yet (the setup wizard's admin,
|
||||
// an account older than the stamp sign-up gives) stores the language it is shown in: the server
|
||||
@@ -98,16 +95,16 @@ export default function BootGate({ children }: { children: ReactNode }) {
|
||||
if (identity === undefined) return;
|
||||
if (readyFor !== undefined) {
|
||||
// Already adopted. Only a DIFFERENT identity reloads: the tree was built for another account —
|
||||
// see the header. The pin stays as it is: the tree being torn down still runs its unmount saves
|
||||
// before the page goes, and with the pin moved they would land under the new account's local
|
||||
// keys. The reloaded page's first probe corrects it. Comparing rather than merely testing for
|
||||
// see the header. The local state is not handed over here: the tree being torn down still runs
|
||||
// its unmount saves before the page goes, and they belong to the account it was built for. The
|
||||
// reloaded page's claim settles it. Comparing rather than merely testing for
|
||||
// "set" is what makes this effect idempotent, so a re-run for any other reason is a no-op
|
||||
// instead of a reload.
|
||||
if (readyFor !== identity) location.reload();
|
||||
return;
|
||||
}
|
||||
const account = adoptedMe();
|
||||
if (account && getActiveAccount() !== account.id) setActiveAccount(account.id);
|
||||
if (account) claimLocalData(account.id);
|
||||
const lang = account?.preferences?.language;
|
||||
if (typeof lang === "string" && isSupported(lang)) {
|
||||
// eslint-disable-next-line local/locale-format -- a one-shot read in an effect, compared before adopting the account's language; nothing renders it, so there is no memoized value to go stale.
|
||||
@@ -121,7 +118,7 @@ export default function BootGate({ children }: { children: ReactNode }) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- the adoption latch. It cannot be derived: the whole point is that the tree mounts only AFTER the pin and the language have been applied, and both are writes to things outside React (sessionStorage, i18n). A layout effect so the frame that first shows the app already has them.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- the adoption latch. It cannot be derived: the whole point is that the tree mounts only AFTER the local state is claimed and the language applied, and both are writes to things outside React (storage, i18n). A layout effect so the frame that first shows the app already has them.
|
||||
setReadyFor(identity);
|
||||
}, [identity, readyFor, qc]);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { qk } from "../lib/queryKeys";
|
||||
import { invalidateSubscription } from "../lib/subscriptionCache";
|
||||
import { UserPlus } from "lucide-react";
|
||||
import { api, type DiscoveredChannel } from "../lib/api";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { LS, ownKey } from "../lib/storage";
|
||||
import { formatCountOrDash } from "../lib/format";
|
||||
import { sortRows } from "../lib/columnSort";
|
||||
import { useSortState } from "../lib/useSortState";
|
||||
@@ -234,7 +234,7 @@ export default function ChannelDiscovery({
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
|
||||
persistKey={ownKey(LS.channelDiscoveryTable) ?? undefined}
|
||||
controlsPosition="both"
|
||||
controlsInBand
|
||||
controlsBandClassName="px-4 max-w-384 mx-auto"
|
||||
|
||||
@@ -16,7 +16,7 @@ import ExcludedChannelList from "./ExcludedChannelList";
|
||||
import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { LS, ownKey } from "../lib/storage";
|
||||
import { buildChannelColumns, applySyncFilter, tagFilterChips } from "./channelColumns";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { Badge } from "./ui/Badge";
|
||||
@@ -824,7 +824,7 @@ export default function Channels() {
|
||||
rows={syncedChannels}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
persistKey={accountKey(LS.channelsTable) ?? undefined}
|
||||
persistKey={ownKey(LS.channelsTable) ?? undefined}
|
||||
// Folded, the status / pager row leaves the band; the list's own end keeps it.
|
||||
controlsPosition={fold.folded ? "bottom" : "both"}
|
||||
controlsLeading={statusChips}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// and a genuine browser Back (popstate carrying `_chTab`) restores + re-persists it. This wiring was
|
||||
// re-routed through useAccountPersistedState, so it's exercised here rather than only by hand E2E.
|
||||
//
|
||||
// Uses REAL storage (jsdom localStorage/sessionStorage) with the tab's account pinned to 1, 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
|
||||
// navigationContext (the sole runtime dependency) is mocked, pinned to the channels page.
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
@@ -18,16 +18,17 @@ vi.mock("./navigationContext", () => ({
|
||||
|
||||
import { ChannelsProvider } from "./ChannelsProvider";
|
||||
import { useChannels, useChannelsActions } from "./channelsContext";
|
||||
import { claimLocalData } from "../lib/storage";
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ChannelsProvider>{children}</ChannelsProvider>
|
||||
);
|
||||
const render = () => renderHook(() => ({ ...useChannels(), ...useChannelsActions() }), { wrapper });
|
||||
const VIEW_KEY = "siftlode.channelsView.1"; // account-scoped (accountKey suffixes the pinned id)
|
||||
const VIEW_KEY = "siftlode.channelsView"; // the account's (claimed in beforeEach)
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.setItem("siftlode.activeAccount", "1"); // pin the tab's account
|
||||
claimLocalData(1); // the browser holds account 1's state
|
||||
// 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.
|
||||
@@ -107,10 +108,10 @@ describe("ChannelsProvider — shareable URL", () => {
|
||||
// Regression: the seed's base for the OMITTED dimensions used to be read as defaults, and
|
||||
// persisting the seed overwrote the account's real stored sort + filter map.
|
||||
localStorage.setItem(
|
||||
"siftlode.channelSort.1",
|
||||
"siftlode.channelSort",
|
||||
JSON.stringify({ key: "subscribers", dir: "desc" }),
|
||||
);
|
||||
localStorage.setItem("siftlode.channelsFilterMap.1", JSON.stringify({ sync: "needsFull" }));
|
||||
localStorage.setItem("siftlode.channelsFilterMap", JSON.stringify({ sync: "needsFull" }));
|
||||
window.history.replaceState({ sfPage: "channels" }, "", "/?ctab=hidden"); // only the tab in the URL
|
||||
|
||||
const { result } = render();
|
||||
@@ -120,11 +121,11 @@ describe("ChannelsProvider — shareable URL", () => {
|
||||
// ...but the omitted sort + filters keep the account's real stored values, in state AND storage.
|
||||
expect(result.current.sort).toEqual({ key: "subscribers", dir: "desc" });
|
||||
expect(result.current.filters).toEqual({ sync: "needsFull" });
|
||||
expect(JSON.parse(localStorage.getItem("siftlode.channelSort.1")!)).toEqual({
|
||||
expect(JSON.parse(localStorage.getItem("siftlode.channelSort")!)).toEqual({
|
||||
key: "subscribers",
|
||||
dir: "desc",
|
||||
});
|
||||
expect(JSON.parse(localStorage.getItem("siftlode.channelsFilterMap.1")!)).toEqual({
|
||||
expect(JSON.parse(localStorage.getItem("siftlode.channelsFilterMap")!)).toEqual({
|
||||
sync: "needsFull",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,30 +5,31 @@
|
||||
// the saved-view and URL paths do — so an upgrading user's topic any/all match isn't silently lost.
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { loadAccountFilters } from "./feedFiltersInit";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { claimLocalData, LS } from "../lib/storage";
|
||||
|
||||
const ID = 7;
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
claimLocalData(7);
|
||||
});
|
||||
|
||||
describe("loadAccountFilters — legacy tagMode migration on seed", () => {
|
||||
it("migrates a persisted legacy tagMode:'and' from the last-session blob into topic", () => {
|
||||
localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ q: "hi", tagMode: "and" }));
|
||||
const f = loadAccountFilters(ID);
|
||||
localStorage.setItem(LS.filters, JSON.stringify({ q: "hi", tagMode: "and" }));
|
||||
const f = loadAccountFilters();
|
||||
expect(f.tagModes).toEqual({ language: "or", topic: "and", other: "or" });
|
||||
expect(f.q).toBe("hi"); // other fields preserved through the merge
|
||||
});
|
||||
|
||||
it("leaves tagModes at defaults when nothing is stored", () => {
|
||||
expect(loadAccountFilters(ID).tagModes).toEqual({ language: "or", topic: "or", other: "or" });
|
||||
expect(loadAccountFilters().tagModes).toEqual({ language: "or", topic: "or", other: "or" });
|
||||
});
|
||||
|
||||
it("preserves an already-migrated per-category tagModes blob", () => {
|
||||
localStorage.setItem(
|
||||
accountKey(LS.filters, ID)!,
|
||||
LS.filters,
|
||||
JSON.stringify({ tagModes: { language: "and", topic: "or", other: "and" } }),
|
||||
);
|
||||
expect(loadAccountFilters(ID).tagModes).toEqual({
|
||||
expect(loadAccountFilters().tagModes).toEqual({
|
||||
language: "and",
|
||||
topic: "or",
|
||||
other: "and",
|
||||
|
||||
@@ -8,9 +8,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { NavigationProvider } from "./NavigationProvider";
|
||||
import { FeedFiltersProvider } from "./FeedFiltersProvider";
|
||||
import { useFeedFilters } from "./feedFiltersContext";
|
||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import type { Me } from "../lib/api";
|
||||
import { claimLocalData } from "../lib/storage";
|
||||
|
||||
const scopes: string[] = [];
|
||||
function Probe() {
|
||||
@@ -34,7 +34,7 @@ function mount(me: Partial<Me>) {
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5");
|
||||
claimLocalData(5);
|
||||
scopes.length = 0;
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
@@ -53,6 +53,6 @@ describe("FeedFiltersProvider seed", () => {
|
||||
window.history.replaceState(null, "", "/?scope=all");
|
||||
mount({});
|
||||
expect(scopes[0]).toBe("all");
|
||||
expect(JSON.parse(localStorage.getItem("siftlode.filters.5") ?? "{}").scope).toBe("all");
|
||||
expect(JSON.parse(localStorage.getItem("siftlode.filters") ?? "{}").scope).toBe("all");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { getActiveAccount, type FeedFilters } from "../lib/api";
|
||||
import { type FeedFilters } from "../lib/api";
|
||||
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
|
||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||
import { accountKey, writeJSON } from "../lib/storage";
|
||||
import { ownKey, writeJSON } from "../lib/storage";
|
||||
import { logFilterChange } from "../lib/filterLog";
|
||||
import { useNavigation } from "./navigationContext";
|
||||
import {
|
||||
@@ -29,12 +29,12 @@ import { DEFAULT_FILTERS, FILTERS_KEY, loadAccountFilters } from "./feedFiltersI
|
||||
function loadInitialFilters(): FeedFilters {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS);
|
||||
return loadAccountFilters(getActiveAccount());
|
||||
return loadAccountFilters();
|
||||
}
|
||||
|
||||
export function FeedFiltersProvider({ children }: { children: ReactNode }) {
|
||||
// Seeded for the account this tab is — BootGate mounts this provider only once the account is
|
||||
// pinned, so this is the account's own view from the first render.
|
||||
// claimed, so this is the account's own view from the first render.
|
||||
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
||||
// Whether this load arrived with feed-filter params in the URL (a bookmark/shared link). Captured
|
||||
// once at mount — such filters win over the account's stored view and are persisted to the account.
|
||||
@@ -45,8 +45,8 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
|
||||
const setFilters = useCallback((next: FeedFilters) => {
|
||||
setFiltersState(next);
|
||||
logFilterChange("feed", next, next.q);
|
||||
// Persist under THIS tab's active account so filters don't bleed between accounts.
|
||||
const key = accountKey(FILTERS_KEY, getActiveAccount());
|
||||
// Persist as the account's own view.
|
||||
const key = ownKey(FILTERS_KEY);
|
||||
if (key) writeJSON(key, next);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { formatBytes, formatDateTime, relativeTime } from "../lib/format";
|
||||
import { focusNavItem } from "../lib/listNav";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { LS, accountKey } from "../lib/storage";
|
||||
import { LS, ownKey } from "../lib/storage";
|
||||
import { useCardPager } from "../lib/useCardPager";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
@@ -628,7 +628,7 @@ export default function Files() {
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
rowKey={(f) => String(f.id)}
|
||||
persistKey={accountKey(LS.filesTable) ?? undefined}
|
||||
persistKey={ownKey(LS.filesTable) ?? undefined}
|
||||
pageSize={50}
|
||||
keyboardNav
|
||||
fixedLayout
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// FilesProvider's arrival at one file: `openFile` (a notice's "Open") and a `?item=` link both make a
|
||||
// pending focus that the Files page spends once it has looked; what it found is held until the page
|
||||
// is left. Real storage with the tab's account pinned; only the navigation is mocked, its page
|
||||
// is left. Real storage, claimed for the account; only the navigation is mocked, its page
|
||||
// switchable.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
@@ -16,6 +16,7 @@ vi.mock("./navigationContext", () => ({
|
||||
|
||||
import { FilesProvider } from "./FilesProvider";
|
||||
import { ALL_FILES, useFiles, useFilesActions } from "./filesContext";
|
||||
import { claimLocalData } from "../lib/storage";
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<FilesProvider>{children}</FilesProvider>
|
||||
@@ -24,7 +25,7 @@ const render = () => renderHook(() => ({ ...useFiles(), ...useFilesActions() }),
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.setItem("siftlode.activeAccount", "1");
|
||||
claimLocalData(1);
|
||||
window.history.replaceState(null, "", "/");
|
||||
nav.page = "files";
|
||||
nav.setPage.mockClear();
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The account panel of the rail: one account per browser session, so it lists no other accounts. It
|
||||
// offers "Sign in as someone else" (straight to Google's account chooser, whose pick replaces this
|
||||
// account) where the instance has Google sign-in, and "Sign out", which ends the session.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import i18n from "../i18n";
|
||||
import { api, type Me } from "../lib/api";
|
||||
import NavSidebar from "./NavSidebar";
|
||||
|
||||
vi.mock("./navigationContext", () => ({
|
||||
useNavigation: () => ({ page: "feed" }),
|
||||
useNavigationActions: () => ({ setPage: vi.fn() }),
|
||||
}));
|
||||
vi.mock("./prefsContext", () => ({ usePrefs: () => ({ railLabels: false }) }));
|
||||
|
||||
const me = (over: Partial<Me> = {}) =>
|
||||
({
|
||||
id: 5,
|
||||
email: "me@example.com",
|
||||
display_name: "Me",
|
||||
role: "user",
|
||||
avatar_url: null,
|
||||
google_email: null,
|
||||
google_enabled: true,
|
||||
plex_enabled: false,
|
||||
preferences: {},
|
||||
...over,
|
||||
}) as unknown as Me;
|
||||
|
||||
const row = (panel: HTMLElement, text: string) =>
|
||||
[...panel.querySelectorAll("button")].find((b) => b.textContent === text)!;
|
||||
|
||||
let href = "";
|
||||
const reload = vi.fn();
|
||||
|
||||
async function mount(account: Me) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<NavSidebar me={account} onOpenAbout={() => {}} onChangeLanguage={() => {}} language="en" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Me · / }));
|
||||
// Positioned by floating-ui, which never settles in jsdom: the panel stays `visibility: hidden`.
|
||||
return vi.waitFor(() => {
|
||||
const panel = document.querySelector<HTMLElement>('[role="dialog"][aria-label="Me"]');
|
||||
if (!panel) throw new Error("the account panel did not open");
|
||||
return panel;
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
await i18n.changeLanguage("en");
|
||||
href = "";
|
||||
reload.mockReset();
|
||||
vi.stubGlobal("location", {
|
||||
...window.location,
|
||||
reload,
|
||||
set href(v: string) {
|
||||
href = v;
|
||||
},
|
||||
get href() {
|
||||
return href;
|
||||
},
|
||||
});
|
||||
vi.spyOn(api, "notificationUnreadCount").mockResolvedValue({ count: 0 } as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("the account panel", () => {
|
||||
it("offers signing in as someone else, which goes straight to Google's account chooser", async () => {
|
||||
const panel = await mount(me());
|
||||
const rows = [...panel.querySelectorAll("button")].map((b) => b.textContent?.trim());
|
||||
expect(rows).toEqual(["Sign in as someone else", "Sign out"]);
|
||||
|
||||
fireEvent.click(row(panel, "Sign in as someone else"));
|
||||
expect(href).toBe("/auth/login");
|
||||
});
|
||||
|
||||
it("offers only signing out where the instance has no Google sign-in", async () => {
|
||||
const panel = await mount(me({ google_enabled: false }));
|
||||
const rows = [...panel.querySelectorAll("button")].map((b) => b.textContent?.trim());
|
||||
expect(rows).toEqual(["Sign out"]);
|
||||
});
|
||||
|
||||
it("signing out ends the session, then reloads onto the sign-in page", async () => {
|
||||
const logout = vi.spyOn(api, "logout").mockResolvedValue({ ok: true });
|
||||
const panel = await mount(me());
|
||||
|
||||
fireEvent.click(row(panel, "Sign out"));
|
||||
|
||||
await vi.waitFor(() => expect(reload).toHaveBeenCalledOnce());
|
||||
expect(logout).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useState, useSyncExternalStore } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigation, useNavigationActions } from "./navigationContext";
|
||||
import { Info, LogOut, Settings, Shield, UserPlus, type LucideIcon } from "lucide-react";
|
||||
import { ArrowLeftRight, Info, LogOut, Settings, Shield, type LucideIcon } from "lucide-react";
|
||||
import { api, type Me } from "../lib/api";
|
||||
import { displayName, roleKey } from "../lib/api/account";
|
||||
import { pinOnNextPage } from "../lib/activeAccount";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { getUnreadCount, subscribe } from "../lib/notifications";
|
||||
import type { Page } from "../lib/urlState";
|
||||
@@ -59,8 +57,8 @@ export default function NavSidebar({
|
||||
// keyboard focus (a rest-to-show flyout via Tooltip), so the names are one glance away without the
|
||||
// rail ever growing.
|
||||
const [acctOpen, setAcctOpen] = useState(false);
|
||||
// The account panel holds mixed content with its own tab order (profile, account switches, two
|
||||
// actions), so it is a `dialog` popover, not a roving menu. It portals out of the nav — whose
|
||||
// The account panel holds mixed content with its own tab order (the profile, then its actions),
|
||||
// so it is a `dialog` popover, not a roving menu. It portals out of the nav — whose
|
||||
// backdrop-filter would otherwise trap fixed positioning + stacking and let clicks fall through
|
||||
// to the controls behind it — and anchors to the RIGHT of the button, bottom-aligned, as its
|
||||
// hand-rolled placement did. Dismissal (outside press, topmost-only Escape, focus return) now
|
||||
@@ -76,41 +74,15 @@ export default function NavSidebar({
|
||||
const { setReference: setAcctAnchor } = acctPop.refs;
|
||||
|
||||
async function logout() {
|
||||
// Signs THIS tab's active account out of the browser wallet (server is per-tab aware); then
|
||||
// drops this tab's override and reloads onto whatever account remains the default.
|
||||
// Ends this browser's session, then reloads onto the sign-in page.
|
||||
try {
|
||||
await api.logout();
|
||||
} catch {
|
||||
/* clear locally and reload regardless */
|
||||
/* reload regardless: the page then shows whatever the server says */
|
||||
}
|
||||
pinOnNextPage(null);
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// Accounts that have signed in on this browser (loaded only while the popover is open).
|
||||
const accountsQuery = useQuery({
|
||||
queryKey: qk.accounts(),
|
||||
queryFn: api.accounts,
|
||||
enabled: acctOpen,
|
||||
});
|
||||
const otherAccounts = (accountsQuery.data ?? []).filter((a) => !a.active);
|
||||
|
||||
function switchTo(id: number) {
|
||||
// Per-tab switch: point THIS tab at the account and reload; other tabs keep their own
|
||||
// identity. No server round-trip — req() sends the account header (validated against the
|
||||
// browser wallet server-side), so the cookie's default account is left untouched. The pin moves with
|
||||
// the reload, not before it: the page going away still unloads as the account it was built for.
|
||||
pinOnNextPage(id);
|
||||
// Drop the previous account's in-module sub-view/overlay before the reload (which would
|
||||
// otherwise preserve history.state across the swap). Otherwise the new account lands on a
|
||||
// stale sub-view that belongs to the OLD identity. Start at the module root instead.
|
||||
const st = { ...(window.history.state || {}) };
|
||||
delete st._sub;
|
||||
delete st._ov;
|
||||
window.history.replaceState(st, "");
|
||||
location.reload(); // cleanest way to reload all per-user state for the new account
|
||||
}
|
||||
|
||||
// Durable, server-backed unread count for the inbox badge. Polled live (pauses when the
|
||||
// tab is hidden); coexists with the client-side transient bell at the bottom of the rail.
|
||||
const unreadQuery = useLiveQuery(qk.notifUnread(), api.notificationUnreadCount, {
|
||||
@@ -270,44 +242,21 @@ export default function NavSidebar({
|
||||
{t("header.account.admin")}
|
||||
</div>
|
||||
)}
|
||||
{otherAccounts.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-0.5">
|
||||
{otherAccounts.map((a) => (
|
||||
<Pressable
|
||||
key={a.id}
|
||||
onClick={() => switchTo(a.id)}
|
||||
title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
|
||||
className="selectable w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg transition touch-target"
|
||||
>
|
||||
<AvatarImg
|
||||
src={a.avatar_url}
|
||||
fallback={a.display_name ?? a.email}
|
||||
className="w-6 h-6 rounded-full text-3xs shrink-0"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[0.8125rem] text-fg truncate">
|
||||
{displayName(a)}
|
||||
</span>
|
||||
<span className="block text-2xs text-muted truncate">{a.email}</span>
|
||||
</span>
|
||||
</Pressable>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 pt-2 border-t border-border flex flex-col">
|
||||
<Pressable
|
||||
onClick={() => {
|
||||
// Adding an account switches THIS tab to it: drop the tab's pin so that on
|
||||
// return from Google it adopts the freshly-added account (the new default)
|
||||
// and pins that. Other tabs keep their own pinned account.
|
||||
pinOnNextPage(null);
|
||||
window.location.href = "/auth/login";
|
||||
}}
|
||||
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg transition touch-target"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
{t("header.account.addAccount")}
|
||||
</Pressable>
|
||||
{/* Straight to Google's account chooser: the account picked there takes this one's
|
||||
place in the browser (one account per session), and a cancel there leaves this
|
||||
one signed in. A password account signs in from the sign-in page, after Sign out. */}
|
||||
{me.google_enabled && (
|
||||
<Pressable
|
||||
onClick={() => {
|
||||
window.location.href = "/auth/login";
|
||||
}}
|
||||
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg transition touch-target"
|
||||
>
|
||||
<ArrowLeftRight className="w-4 h-4" />
|
||||
{t("header.account.signInAsOther")}
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable
|
||||
onClick={logout}
|
||||
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg transition touch-target"
|
||||
|
||||
@@ -31,7 +31,7 @@ import { useBackToClose } from "../lib/history";
|
||||
import { PageHeading } from "./ui/Headings";
|
||||
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { LS, setAccountRaw } from "../lib/storage";
|
||||
import { claimLocalData, LS, setAccountRaw } from "../lib/storage";
|
||||
import { PAGE_KEY, START_ON_HOME } from "./navigationInit";
|
||||
|
||||
/** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs
|
||||
@@ -82,7 +82,7 @@ beforeEach(() => {
|
||||
// An admin on a Plex-enabled instance: every module is open to it, so no gate moves a page here.
|
||||
qc.setQueryData(qk.me(), { id: 1, role: "admin", plex_enabled: true });
|
||||
localStorage.clear();
|
||||
sessionStorage.setItem("siftlode.activeAccount", "1"); // account-scoped page memory
|
||||
claimLocalData(1); // the account's page memory
|
||||
window.history.replaceState({ sfPage: "feed" }, "", "/");
|
||||
});
|
||||
// Not automatic: vitest runs without `globals`, so RTL never registers its own afterEach — leaving a
|
||||
|
||||
@@ -23,8 +23,8 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
|
||||
import PlexPlayer from "./PlexPlayer";
|
||||
import { api, type PlexItemDetail } from "../lib/api";
|
||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||
import { pushLayer, removeLayer } from "../lib/layerStack";
|
||||
import { claimLocalData } from "../lib/storage";
|
||||
|
||||
type Media = HTMLMediaElement & { _t?: number; _paused?: boolean };
|
||||
|
||||
@@ -87,7 +87,7 @@ beforeAll(() => {
|
||||
let play: ReturnType<typeof vi.fn>;
|
||||
let pause: ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7");
|
||||
claimLocalData(7);
|
||||
play = vi.fn(function (this: Media) {
|
||||
this._paused = false;
|
||||
this.dispatchEvent(new Event("play"));
|
||||
@@ -1034,7 +1034,7 @@ describe("PlexPlayer — clip mode", () => {
|
||||
});
|
||||
|
||||
const prefs = (p: object) =>
|
||||
localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en", ...p }));
|
||||
localStorage.setItem("siftlode.plexPlayerPrefs", JSON.stringify({ subLang: "en", ...p }));
|
||||
const infoOverlay = () => document.querySelector("[class*='bg-black/70']");
|
||||
|
||||
it("Escape closes what is over the picture first, then the crop and the panel", async () => {
|
||||
|
||||
@@ -50,7 +50,7 @@ vi.mock("hls.js", async (importOriginal) => {
|
||||
import Hls from "hls.js";
|
||||
import PlexPlayer from "./PlexPlayer";
|
||||
import { api, type PlexItemDetail } from "../lib/api";
|
||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||
import { claimLocalData } from "../lib/storage";
|
||||
|
||||
type Media = HTMLMediaElement & { _t?: number };
|
||||
|
||||
@@ -91,7 +91,7 @@ beforeAll(() => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7");
|
||||
claimLocalData(7);
|
||||
hlsInstances.length = 0;
|
||||
});
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import PlexPlayer from "./PlexPlayer";
|
||||
import { api, HttpError, type PlexItemDetail } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||
import { claimLocalData } from "../lib/storage";
|
||||
|
||||
type Media = HTMLMediaElement & { _t?: number };
|
||||
|
||||
@@ -75,8 +75,8 @@ beforeAll(() => {
|
||||
|
||||
beforeEach(() => {
|
||||
// A signed-in account whose saved subtitle language is English (prefs are per account).
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7");
|
||||
localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en" }));
|
||||
claimLocalData(7);
|
||||
localStorage.setItem("siftlode.plexPlayerPrefs", JSON.stringify({ subLang: "en" }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -310,7 +310,7 @@ const startsAsked = (id: string) =>
|
||||
|
||||
describe("leaving an item that was played through", () => {
|
||||
const prefs = (p: object) =>
|
||||
localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en", ...p }));
|
||||
localStorage.setItem("siftlode.plexPlayerPrefs", JSON.stringify({ subLang: "en", ...p }));
|
||||
|
||||
// The end of the media, in the order the HTML spec mandates: the browser sets `paused`, fires
|
||||
// 'pause', and only THEN fires 'ended'. Dispatching a bare 'ended' models a browser that does not
|
||||
|
||||
@@ -9,7 +9,7 @@ import { render, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { NavigationProvider } from "./NavigationProvider";
|
||||
import { PlexProvider } from "./PlexProvider";
|
||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||
import { claimLocalData } from "../lib/storage";
|
||||
|
||||
function mount() {
|
||||
render(
|
||||
@@ -25,7 +25,7 @@ function mount() {
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5");
|
||||
claimLocalData(5);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -37,9 +37,9 @@ describe("PlexProvider shared link", () => {
|
||||
window.history.replaceState(null, "", "/?page=plex&pscope=movie");
|
||||
mount();
|
||||
|
||||
expect(localStorage.getItem("siftlode.plexLibrary.5")).toBe("movie");
|
||||
expect(localStorage.getItem("siftlode.plexFilters.5")).toBeNull();
|
||||
expect(localStorage.getItem("siftlode.plexSort.5")).toBeNull();
|
||||
expect(localStorage.getItem("siftlode.plexShow.5")).toBeNull();
|
||||
expect(localStorage.getItem("siftlode.plexLibrary")).toBe("movie");
|
||||
expect(localStorage.getItem("siftlode.plexFilters")).toBeNull();
|
||||
expect(localStorage.getItem("siftlode.plexSort")).toBeNull();
|
||||
expect(localStorage.getItem("siftlode.plexShow")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,8 +10,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { PrefsProvider } from "./PrefsProvider";
|
||||
import { usePrefs } from "./prefsContext";
|
||||
import { api } from "../lib/api";
|
||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { claimLocalData, LS } from "../lib/storage";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
const seen: { perf: boolean; hints: boolean }[] = [];
|
||||
@@ -23,7 +22,7 @@ function Probe() {
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5");
|
||||
claimLocalData(5);
|
||||
seen.length = 0;
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
@@ -35,8 +34,8 @@ afterEach(() => {
|
||||
|
||||
describe("PrefsProvider seed", () => {
|
||||
it("renders the account's server prefs from the first render, and does not save them back", () => {
|
||||
localStorage.setItem(accountKey(LS.perfMode, 5)!, "0"); // this browser last had it off
|
||||
localStorage.setItem(accountKey(LS.hints, 5)!, "1");
|
||||
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 } });
|
||||
|
||||
@@ -45,8 +45,7 @@ vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
import { SavedViewsProvider } from "./SavedViewsProvider";
|
||||
import { useSavedViews } from "./savedViewsContext";
|
||||
import { api, type SavedView } from "../lib/api";
|
||||
import { LS, accountKey } from "../lib/storage";
|
||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||
import { claimLocalData, LS } from "../lib/storage";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
const ID = 7;
|
||||
@@ -95,7 +94,7 @@ async function mountResolved(views: SavedView[]) {
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(ID));
|
||||
claimLocalData(ID);
|
||||
// Reset the URL: feedCanSeedDefault reads window.location.search, and a stale ?sort= from a prior
|
||||
// case would suppress seeding in the others.
|
||||
window.history.replaceState({}, "", "/");
|
||||
@@ -118,7 +117,7 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => {
|
||||
});
|
||||
|
||||
it("feed: does NOT seed when the account already has stored filters", async () => {
|
||||
localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ scope: "my" }));
|
||||
localStorage.setItem(LS.filters, JSON.stringify({ scope: "my" }));
|
||||
await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]);
|
||||
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -177,7 +176,7 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => {
|
||||
|
||||
it("plex: does NOT seed when plex filters are already stored", async () => {
|
||||
h.page = "plex";
|
||||
localStorage.setItem(accountKey(LS.plexFilters, ID)!, JSON.stringify({ genres: ["Comedy"] }));
|
||||
localStorage.setItem(LS.plexFilters, JSON.stringify({ genres: ["Comedy"] }));
|
||||
await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
|
||||
expect(h.setPlexFilters).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import { qk } from "../lib/queryKeys";
|
||||
import {
|
||||
api,
|
||||
coercePlexFilters,
|
||||
getActiveAccount,
|
||||
plexFiltersSignature,
|
||||
type FeedFilters,
|
||||
type PlexFilters,
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
import { filtersToParams, hasFilterParams } from "../lib/urlState";
|
||||
import { pickDefaultToSeed, preferSticky } from "../lib/savedViews";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { LS, ownKey } from "../lib/storage";
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import { useNavigation } from "./navigationContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
@@ -55,7 +54,7 @@ const plexKeyOf = (f: Record<string, unknown>) => plexFiltersSignature(coercePle
|
||||
// view / manual tweaks (not the default) should win. Account unknown or localStorage unavailable →
|
||||
// report "stored" so a default never seeds blind over an indeterminate state.
|
||||
function storedFiltersPresent(lsKey: string): boolean {
|
||||
const key = accountKey(lsKey, getActiveAccount());
|
||||
const key = ownKey(lsKey);
|
||||
if (!key) return true;
|
||||
try {
|
||||
return localStorage.getItem(key) != null;
|
||||
|
||||
@@ -8,8 +8,7 @@ import { act, cleanup, render, renderHook, screen, waitFor } from "@testing-libr
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import { api, type FeedFilters, type ManagedChannel } from "../lib/api";
|
||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { claimLocalData, LS } from "../lib/storage";
|
||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||
import ActiveFilterChips from "./ActiveFilterChips";
|
||||
import ChannelPicker from "./ChannelPicker";
|
||||
@@ -28,7 +27,7 @@ const filters: FeedFilters = {
|
||||
...clearedFilters({ q: "" } as FeedFilters),
|
||||
channelIds: [KNOWN, NEW],
|
||||
};
|
||||
const namesKey = () => accountKey(LS.channelNames, 5)!;
|
||||
const namesKey = () => LS.channelNames;
|
||||
|
||||
let resolveChannels: (c: ManagedChannel[]) => void;
|
||||
let qc: QueryClient;
|
||||
@@ -37,7 +36,7 @@ const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5");
|
||||
claimLocalData(5);
|
||||
localStorage.setItem(namesKey(), JSON.stringify({ [KNOWN]: "Remembered name" }));
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
vi.spyOn(api, "tags").mockResolvedValue([] as never);
|
||||
|
||||
@@ -61,10 +61,7 @@ export function JobThumb({ job, box = THUMB_BOX }: { job: DownloadJob; box?: str
|
||||
// rule on the source domain can't break it. The source's remote thumbnail_url is only a fallback for
|
||||
// the rare poster-less asset (a thumbnail-less source whose ffmpeg frame-cut also failed).
|
||||
const remote = job.asset?.thumbnail_url ?? null;
|
||||
// `job.poster_url` only SIGNALS a poster exists; the actual <img> src must carry ?account= (see
|
||||
// api.posterImageUrl). A plain <img> can't send the X-Siftlode-Account header, so without it the
|
||||
// poster resolves to the session-default account and 404s for a per-tab account's download — which,
|
||||
// now that the poster is the primary image, reads as a blank Library thumbnail.
|
||||
// `job.poster_url` only SIGNALS a poster exists; the <img> src is api.posterImageUrl.
|
||||
const poster = job.poster_url ? api.posterImageUrl(job.id) : null;
|
||||
// A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/
|
||||
// archive, not playable media. An image is its own picture (its sandboxed preview); the rest show
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { coerceTagModes, coerceUntagged, DEFAULT_TAG_MODES, type FeedFilters } from "../lib/api";
|
||||
import { accountKey, LS, readJSON } from "../lib/storage";
|
||||
import { LS, ownKey, readJSON } from "../lib/storage";
|
||||
|
||||
// The feed filters' defaults and the per-account localStorage seed, split out of FeedFiltersProvider
|
||||
// (like navigationInit / playlistsInit) so that file exports only components (Fast Refresh).
|
||||
@@ -23,16 +23,16 @@ export const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"]
|
||||
|
||||
export const FILTERS_KEY = LS.filters;
|
||||
|
||||
// This account's own persisted filters (per-account keys — never another account's). No account
|
||||
// This account's own persisted filters (see ownKey). No account
|
||||
// (signed out) → defaults.
|
||||
export function loadAccountFilters(id: number | null): FeedFilters {
|
||||
export function loadAccountFilters(): FeedFilters {
|
||||
// Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
|
||||
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. A starred
|
||||
// default view seeds a fresh account that has never stored filters, but that is applied ONCE by
|
||||
// SavedViewsProvider on its views query's first resolve (which then persists here like any edit) —
|
||||
// not read from a mirror on this synchronous path, which used to race that resolve. So here we only
|
||||
// ever read the account's own stored filters, else fall back to DEFAULT_FILTERS.
|
||||
const fKey = accountKey(FILTERS_KEY, id);
|
||||
const fKey = ownKey(FILTERS_KEY);
|
||||
let hasStored = false;
|
||||
try {
|
||||
hasStored = !!fKey && localStorage.getItem(fKey) != null;
|
||||
|
||||
@@ -32,7 +32,7 @@ const PLAIN: Record<string, [number, string]> = {
|
||||
"the hero's picture and a shelf tile's picture (each named by aria-label; its title sits outside)",
|
||||
],
|
||||
"LanguageSwitcher.tsx": [1, "an option row"],
|
||||
"NavSidebar.tsx": [4, "the account trigger and its menu rows"],
|
||||
"NavSidebar.tsx": [3, "the account trigger and its menu rows"],
|
||||
"PaletteLauncher.tsx": [2, "launcher controls"],
|
||||
"PlaylistsRail.tsx": [1, "the load-error retry"],
|
||||
"PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
|
||||
|
||||
Reference in New Issue
Block a user