fix(feed): scope saved filters + default view per account

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.<id> / siftlode.defaultViewFilters.<id>);
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).
This commit is contained in:
npeter83 2026-07-02 00:37:23 +02:00
parent f32c3f08dc
commit 59de0ffdfd
3 changed files with 63 additions and 22 deletions

View file

@ -26,7 +26,7 @@ import {
} from "./lib/sidebarLayout"; } from "./lib/sidebarLayout";
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications"; import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
import { hintsEnabled, setHintsEnabled } from "./lib/hints"; 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 { useConfirm } from "./components/ConfirmProvider";
import type { PrefsController } from "./components/SettingsPanel"; import type { PrefsController } from "./components/SettingsPanel";
import Welcome from "./components/Welcome"; import Welcome from "./components/Welcome";
@ -93,19 +93,23 @@ function loadInitialPage(): Page {
return isPage(stored) ? stored : "feed"; return isPage(stored) ? stored : "feed";
} }
function loadStoredFilters(): FeedFilters { // This account's own persisted filters (per-account keys — never another account's). A chosen
return readMerged(FILTERS_KEY, DEFAULT_FILTERS); // 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<FeedFilters | null>(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. // URL wins over localStorage so a pasted link reproduces the exact view.
function loadInitialFilters(): FeedFilters { function loadInitialFilters(): FeedFilters {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS); if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS);
// A user-chosen default saved view takes precedence over the last-session filters on a fresh return loadAccountFilters(getActiveAccount());
// load / F5 (SavedViewsWidget mirrors its filters here). No default set → last-session filters.
const def = readJSON<FeedFilters | null>(LS.defaultViewFilters, null);
if (def) return { ...DEFAULT_FILTERS, ...def };
return loadStoredFilters();
} }
export default function App() { export default function App() {
@ -119,6 +123,11 @@ export default function App() {
const pageRef = useRef<Page>("feed"); const pageRef = useRef<Page>("feed");
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme()); const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters); const [filters, setFiltersState] = useState<FeedFilters>(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"); const [view, setView] = useState<"grid" | "list">("grid");
// Settings-page prefs draft (apply live, persist on Save — see EditablePrefs). // Settings-page prefs draft (apply live, persist on Save — see EditablePrefs).
const [perf, setPerf] = useState(() => localStorage.getItem(PERF_KEY) === "1"); const [perf, setPerf] = useState(() => localStorage.getItem(PERF_KEY) === "1");
@ -224,7 +233,9 @@ export default function App() {
function setFilters(next: FeedFilters) { function setFilters(next: FeedFilters) {
setFiltersState(next); 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) { function setPage(next: Page) {
@ -301,7 +312,8 @@ export default function App() {
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
if (hasFilterParams(params) || params.has("page")) { 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(); stripUrlParams();
} }
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
@ -383,6 +395,26 @@ export default function App() {
if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id); if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id);
}, [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. // 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 // ?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. // 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); writeJSON(LS.filterCollapsed, prefs.filterCollapsed);
} }
if (isSupported(prefs.language)) setLanguage(prefs.language); if (isSupported(prefs.language)) setLanguage(prefs.language);
// The demo account is shared: there are no subscriptions, so default it to the whole // (Per-account filters — incl. the demo account's whole-library default — are loaded by the
// library (the "my" feed would be empty). The communal-state warning is a permanent // dedicated effect above, which also covers accounts that have no stored preferences.)
// banner (see DemoBanner below), not a toast that re-pops on every reload.
if (meQuery.data?.is_demo) {
setFilters({ ...loadStoredFilters(), scope: "all" });
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [meQuery.data?.id]); }, [meQuery.data?.id]);

View file

@ -17,10 +17,10 @@ import {
verticalListSortingStrategy, verticalListSortingStrategy,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; 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 { filtersToParams, shareUrl } from "../lib/urlState";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { LS, readJSON, writeJSON } from "../lib/storage"; import { accountKey, LS, readJSON, writeJSON } from "../lib/storage";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
// Compact, order-independent signature of a filter set (reuses the share serializer, which // 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. // saved view that matches the feed's current filters.
const keyOf = (f: FeedFilters) => filtersToParams(f).toString(); const keyOf = (f: FeedFilters) => filtersToParams(f).toString();
/** The default view's filters, or null. Read synchronously on load (App.loadInitialFilters) /** The default view's filters, or null. Read synchronously on load (App.loadAccountFilters) from a
* from a localStorage mirror the widget keeps in sync avoids a flash before the query loads. */ * 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 { export function loadDefaultViewFilters(): FeedFilters | null {
return readJSON<FeedFilters | null>(LS.defaultViewFilters, null); const key = accountKey(LS.defaultViewFilters, getActiveAccount());
return key ? readJSON<FeedFilters | null>(key, null) : null;
} }
function syncDefaultMirror(views: SavedView[]): void { function syncDefaultMirror(views: SavedView[]): void {
const key = accountKey(LS.defaultViewFilters, getActiveAccount());
if (!key) return;
const def = views.find((v) => v.is_default); 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({ export default function SavedViewsWidget({

View file

@ -32,6 +32,15 @@ export const LS = {
// --- typed read/write helpers (do the JSON + try/catch once) ---------------------------- // --- 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), /** Read an object-shaped value merged over `defaults` (so missing/added keys are tolerated),
* falling back to `defaults` on missing or corrupt data. */ * falling back to `defaults` on missing or corrupt data. */
export function readMerged<T extends object>(key: string, defaults: T): T { export function readMerged<T extends object>(key: string, defaults: T): T {