From 59de0ffdfd8ae93ca8fc33e5a9037e152c04c117 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 2 Jul 2026 00:37:23 +0200 Subject: [PATCH] fix(feed): scope saved filters + default view per account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feed filters and the default-saved-view mirror lived under shared localStorage keys, so one account's view (incl. its starred default) leaked into another account signed into the same browser — visible with per-tab accounts as a fresh account showing the previous account's 'N active' filters instead of a clean default. Key both by the tab's active account (siftlode.filters. / siftlode.defaultViewFilters.); load a tab's filters from its own account on login/switch/add, falling back to defaults when the account isn't known yet. A share link's filters still win and are persisted to the account. The old shared keys are simply no longer read (a one-time reset to defaults on the first load after this change; migrating them would risk re-leaking across accounts). --- frontend/src/App.tsx | 60 ++++++++++++++------ frontend/src/components/SavedViewsWidget.tsx | 16 ++++-- frontend/src/lib/storage.ts | 9 +++ 3 files changed, 63 insertions(+), 22 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 58c26ae..72a74cf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,7 +26,7 @@ import { } from "./lib/sidebarLayout"; import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications"; import { hintsEnabled, setHintsEnabled } from "./lib/hints"; -import { LS, readJSON, readMerged, usePersistedState, writeJSON } from "./lib/storage"; +import { accountKey, LS, readJSON, readMerged, usePersistedState, writeJSON } from "./lib/storage"; import { useConfirm } from "./components/ConfirmProvider"; import type { PrefsController } from "./components/SettingsPanel"; import Welcome from "./components/Welcome"; @@ -93,19 +93,23 @@ function loadInitialPage(): Page { return isPage(stored) ? stored : "feed"; } -function loadStoredFilters(): FeedFilters { - return readMerged(FILTERS_KEY, DEFAULT_FILTERS); +// This account's own persisted filters (per-account keys — never another account's). A chosen +// default saved view wins over the last-session filters (SavedViewsWidget mirrors it here). When +// the account isn't known yet (first load, before the tab is pinned), start from defaults; the +// post-login effect loads the real ones once the id arrives. +function loadAccountFilters(id: number | null): FeedFilters { + const dvKey = accountKey(LS.defaultViewFilters, id); + const def = dvKey ? readJSON(dvKey, null) : null; + if (def) return { ...DEFAULT_FILTERS, ...def }; + const fKey = accountKey(LS.filters, id); + return fKey ? readMerged(fKey, DEFAULT_FILTERS) : { ...DEFAULT_FILTERS }; } // URL wins over localStorage so a pasted link reproduces the exact view. function loadInitialFilters(): FeedFilters { const params = new URLSearchParams(window.location.search); if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS); - // A user-chosen default saved view takes precedence over the last-session filters on a fresh - // load / F5 (SavedViewsWidget mirrors its filters here). No default set → last-session filters. - const def = readJSON(LS.defaultViewFilters, null); - if (def) return { ...DEFAULT_FILTERS, ...def }; - return loadStoredFilters(); + return loadAccountFilters(getActiveAccount()); } export default function App() { @@ -119,6 +123,11 @@ export default function App() { const pageRef = useRef("feed"); const [theme, setThemeState] = useState(() => loadLocalTheme()); const [filters, setFiltersState] = useState(loadInitialFilters); + // Whether this load arrived via a "Share view" link (captured once, before the URL is stripped) + // — such filters win over the account's stored view and are persisted to the account instead. + const [initialHadUrlFilters] = useState(() => + hasFilterParams(new URLSearchParams(window.location.search)) + ); const [view, setView] = useState<"grid" | "list">("grid"); // Settings-page prefs draft (apply live, persist on Save — see EditablePrefs). const [perf, setPerf] = useState(() => localStorage.getItem(PERF_KEY) === "1"); @@ -224,7 +233,9 @@ export default function App() { function setFilters(next: FeedFilters) { setFiltersState(next); - localStorage.setItem(FILTERS_KEY, JSON.stringify(next)); + // Persist under THIS tab's active account so filters don't bleed between accounts. + const key = accountKey(FILTERS_KEY, getActiveAccount()); + if (key) writeJSON(key, next); } function setPage(next: Page) { @@ -301,7 +312,8 @@ export default function App() { useEffect(() => { const params = new URLSearchParams(window.location.search); if (hasFilterParams(params) || params.has("page")) { - localStorage.setItem(FILTERS_KEY, JSON.stringify(filters)); + // A share link's filters are held in state; they're persisted to the account's key by the + // post-login effect (which knows the account id). Here we only clean the address bar. stripUrlParams(); } }, []); // eslint-disable-line react-hooks/exhaustive-deps @@ -383,6 +395,26 @@ export default function App() { if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id); }, [meQuery.data?.id]); + // Load THIS account's own filters when the account first resolves or changes (login / switch / + // add). Filters are per-account localStorage state, so another account's last view (incl. its + // default saved view) must never carry over. A share link's filters (captured at mount) win and + // are persisted to the account instead. Runs on id change only, so a 60s background refetch of + // the same account never clobbers the filters you're editing. + useEffect(() => { + const id = meQuery.data?.id; + if (id == null) return; + if (initialHadUrlFilters) { + const key = accountKey(FILTERS_KEY, id); + if (key) writeJSON(key, filters); + return; + } + let f = loadAccountFilters(id); + // The shared demo account has no subscriptions, so default it to the whole library. + if (meQuery.data?.is_demo) f = { ...f, scope: "all" }; + setFiltersState(f); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [meQuery.data?.id]); + // Once signed in, drop any leftover query string from the pre-login flow (e.g. // ?access=requested, ?verify, ?reset). The logged-in app reads nothing from the URL, so the // address bar stays clean. Gated on auth so the logged-out Welcome page still reads its params. @@ -468,12 +500,8 @@ export default function App() { writeJSON(LS.filterCollapsed, prefs.filterCollapsed); } if (isSupported(prefs.language)) setLanguage(prefs.language); - // The demo account is shared: there are no subscriptions, so default it to the whole - // library (the "my" feed would be empty). The communal-state warning is a permanent - // banner (see DemoBanner below), not a toast that re-pops on every reload. - if (meQuery.data?.is_demo) { - setFilters({ ...loadStoredFilters(), scope: "all" }); - } + // (Per-account filters — incl. the demo account's whole-library default — are loaded by the + // dedicated effect above, which also covers accounts that have no stored preferences.) // eslint-disable-next-line react-hooks/exhaustive-deps }, [meQuery.data?.id]); diff --git a/frontend/src/components/SavedViewsWidget.tsx b/frontend/src/components/SavedViewsWidget.tsx index 45d9717..200d777 100644 --- a/frontend/src/components/SavedViewsWidget.tsx +++ b/frontend/src/components/SavedViewsWidget.tsx @@ -17,10 +17,10 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { api, type FeedFilters, type SavedView } from "../lib/api"; +import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api"; import { filtersToParams, shareUrl } from "../lib/urlState"; import { notify } from "../lib/notifications"; -import { LS, readJSON, writeJSON } from "../lib/storage"; +import { accountKey, LS, readJSON, writeJSON } from "../lib/storage"; import { useConfirm } from "./ConfirmProvider"; // Compact, order-independent signature of a filter set (reuses the share serializer, which @@ -28,15 +28,19 @@ import { useConfirm } from "./ConfirmProvider"; // saved view that matches the feed's current filters. const keyOf = (f: FeedFilters) => filtersToParams(f).toString(); -/** The default view's filters, or null. Read synchronously on load (App.loadInitialFilters) - * from a localStorage mirror the widget keeps in sync — avoids a flash before the query loads. */ +/** The default view's filters, or null. Read synchronously on load (App.loadAccountFilters) from a + * per-account localStorage mirror the widget keeps in sync — avoids a flash before the query loads. + * Per-account so one account's default view never seeds another account's feed. */ export function loadDefaultViewFilters(): FeedFilters | null { - return readJSON(LS.defaultViewFilters, null); + const key = accountKey(LS.defaultViewFilters, getActiveAccount()); + return key ? readJSON(key, null) : null; } function syncDefaultMirror(views: SavedView[]): void { + const key = accountKey(LS.defaultViewFilters, getActiveAccount()); + if (!key) return; const def = views.find((v) => v.is_default); - writeJSON(LS.defaultViewFilters, def ? def.filters : null); + writeJSON(key, def ? def.filters : null); } export default function SavedViewsWidget({ diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 20c6536..ea661b6 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -32,6 +32,15 @@ export const LS = { // --- typed read/write helpers (do the JSON + try/catch once) ---------------------------- +/** Suffix a base localStorage key with an account id. Per-account UI state (the feed filters and + * the default-view mirror) must NOT be shared across the accounts signed into one browser — a + * bare shared key leaks one account's view into another (and, with per-tab accounts, two tabs + * would fight over it). Returns null when the account isn't known yet, so callers fall back to + * defaults / skip persistence instead of touching another account's data. */ +export function accountKey(base: string, id: number | null | undefined): string | null { + return id != null ? `${base}.${id}` : null; +} + /** Read an object-shaped value merged over `defaults` (so missing/added keys are tolerated), * falling back to `defaults` on missing or corrupt data. */ export function readMerged(key: string, defaults: T): T {