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";
|
||||
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]);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue