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:
parent
f32c3f08dc
commit
59de0ffdfd
3 changed files with 63 additions and 22 deletions
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue