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";
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<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.
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<FeedFilters | null>(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<Page>("feed");
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
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");
// 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]);