fix(state): scope all per-account localStorage by account id

Multi-account-in-one-browser (esp. with per-tab accounts) leaked one account's client state
into another via shared localStorage keys. Scope every account-specific key by the tab's active
account (accountKey/readAccount/writeAccount/useAccountPersistedState helpers), so nothing bleeds
across accounts or tabs:

- Real leaks: selected playlist, client notification history + settings, onboarding-dismissed.
- UI position: feed page, channel-manager filter/view + tables, playlist sort, Settings/Stats/
  Users/Config tabs.
- Previously DB-adopted caches (theme, hints, performance mode, sidebar layout, nav/filter
  collapse) — now the cache is per-account too, so there's no flash of the other account's value
  on login.

Kept intentionally global: siftlode.lang (needed pre-login on the Welcome page; the DB pref still
scopes it per-account after sign-in) and siftlode.seenVersion (a per-browser 'new version' banner).
E2EE private keys (IndexedDB) and the chat-dock key were already per-user.
This commit is contained in:
npeter83 2026-07-02 01:45:16 +02:00
parent 59de0ffdfd
commit d560825685
15 changed files with 139 additions and 78 deletions

View file

@ -26,7 +26,18 @@ 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 { accountKey, LS, readJSON, readMerged, usePersistedState, writeJSON } from "./lib/storage"; import {
accountKey,
getAccountRaw,
LS,
readAccount,
readJSON,
readMerged,
setAccountRaw,
useAccountPersistedState,
writeAccount,
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";
@ -89,7 +100,7 @@ type EditablePrefs = {
function loadInitialPage(): Page { function loadInitialPage(): Page {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
if (params.has("page")) return readPage(); if (params.has("page")) return readPage();
const stored = localStorage.getItem(PAGE_KEY); const stored = getAccountRaw(PAGE_KEY);
return isPage(stored) ? stored : "feed"; return isPage(stored) ? stored : "feed";
} }
@ -130,13 +141,13 @@ export default function App() {
); );
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(() => getAccountRaw(PERF_KEY) === "1");
const [hints, setHints] = useState(() => hintsEnabled()); const [hints, setHints] = useState(() => hintsEnabled());
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings()); const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({ const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({
theme: loadLocalTheme(), theme: loadLocalTheme(),
view: "grid", view: "grid",
performanceMode: localStorage.getItem(PERF_KEY) === "1", performanceMode: getAccountRaw(PERF_KEY) === "1",
hints: hintsEnabled(), hints: hintsEnabled(),
notifications: getNotifSettings(), notifications: getNotifSettings(),
})); }));
@ -193,7 +204,7 @@ export default function App() {
}, []); }, []);
const [wizardOpen, setWizardOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false);
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render. // Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all"); const [channelFilterRaw, setChannelFilter] = useAccountPersistedState(LS.channelFilter, "all");
const channelFilter: ChannelStatusFilter = ( const channelFilter: ChannelStatusFilter = (
["needs_full", "fully_synced", "hidden", "all"] as const ["needs_full", "fully_synced", "hidden", "all"] as const
).includes(channelFilterRaw as ChannelStatusFilter) ).includes(channelFilterRaw as ChannelStatusFilter)
@ -206,7 +217,7 @@ export default function App() {
// persisted so navigation intents that target the subscriptions table — the header's // persisted so navigation intents that target the subscriptions table — the header's
// "without full history" link, focus-channel — can force it back to "subscribed" instead // "without full history" link, focus-channel — can force it back to "subscribed" instead
// of dumping the user on whatever tab they last left open. // of dumping the user on whatever tab they last left open.
const [channelsViewRaw, setChannelsView] = usePersistedState(LS.channelsView, "subscribed"); const [channelsViewRaw, setChannelsView] = useAccountPersistedState(LS.channelsView, "subscribed");
const channelsView: ChannelsView = const channelsView: ChannelsView =
channelsViewRaw === "discovery" ? "discovery" : "subscribed"; channelsViewRaw === "discovery" ? "discovery" : "subscribed";
// Bumped to tell the channel manager to drop a stale column filter when we send the user // Bumped to tell the channel manager to drop a stale column filter when we send the user
@ -245,7 +256,7 @@ export default function App() {
const go = () => { const go = () => {
setChannelView(null); // leave any open channel page when navigating via the rail setChannelView(null); // leave any open channel page when navigating via the rail
setPageState(next); setPageState(next);
localStorage.setItem(PAGE_KEY, next); setAccountRaw(PAGE_KEY, next);
// Push an in-app history entry so the browser/mouse Back button steps through pages // Push an in-app history entry so the browser/mouse Back button steps through pages
// instead of leaving the app (e.g. back to the OAuth redirect). The URL stays clean — // instead of leaving the app (e.g. back to the OAuth redirect). The URL stays clean —
// the page rides in history.state, not the query string (filters never go in the URL). // the page rides in history.state, not the query string (filters never go in the URL).
@ -279,19 +290,19 @@ export default function App() {
// user's preferences so it follows the account across devices; a localStorage cache seeds the // user's preferences so it follows the account across devices; a localStorage cache seeds the
// initial render synchronously so nothing flashes open before the server prefs arrive. // initial render synchronously so nothing flashes open before the server prefs arrive.
const [navCollapsed, setNavCollapsedState] = useState<boolean>(() => const [navCollapsed, setNavCollapsedState] = useState<boolean>(() =>
Boolean(readJSON(LS.navCollapsed, false)) Boolean(readAccount(LS.navCollapsed, false))
); );
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() => const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
Boolean(readJSON(LS.filterCollapsed, false)) Boolean(readAccount(LS.filterCollapsed, false))
); );
function setNavCollapsed(next: boolean) { function setNavCollapsed(next: boolean) {
setNavCollapsedState(next); setNavCollapsedState(next);
writeJSON(LS.navCollapsed, next); writeAccount(LS.navCollapsed, next);
api.savePrefs({ navCollapsed: next }).catch(() => {}); api.savePrefs({ navCollapsed: next }).catch(() => {});
} }
function setFilterCollapsed(next: boolean) { function setFilterCollapsed(next: boolean) {
setFilterCollapsedState(next); setFilterCollapsedState(next);
writeJSON(LS.filterCollapsed, next); writeAccount(LS.filterCollapsed, next);
api.savePrefs({ filterCollapsed: next }).catch(() => {}); api.savePrefs({ filterCollapsed: next }).catch(() => {});
} }
@ -301,7 +312,7 @@ export default function App() {
// the stores too); persistence to the server is deferred to an explicit Save. // the stores too); persistence to the server is deferred to an explicit Save.
useEffect(() => { useEffect(() => {
document.documentElement.dataset.perf = perf ? "1" : ""; document.documentElement.dataset.perf = perf ? "1" : "";
localStorage.setItem(PERF_KEY, perf ? "1" : "0"); setAccountRaw(PERF_KEY, perf ? "1" : "0");
}, [perf]); }, [perf]);
useEffect(() => setHintsEnabled(hints), [hints]); useEffect(() => setHintsEnabled(hints), [hints]);
useEffect(() => configureNotifications(notif), [notif]); useEffect(() => configureNotifications(notif), [notif]);
@ -348,12 +359,12 @@ export default function App() {
if (!ok) return; if (!ok) return;
discardRef.current(); discardRef.current();
setPageState(p); setPageState(p);
localStorage.setItem(PAGE_KEY, p); setAccountRaw(PAGE_KEY, p);
}); });
return; return;
} }
setPageState(p); setPageState(p);
localStorage.setItem(PAGE_KEY, p); setAccountRaw(PAGE_KEY, p);
// The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or // The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or
// clears it to match the entry we landed on (and a player popped first leaves it intact). // clears it to match the entry we landed on (and a player popped first leaves it intact).
setYtSearch((e.state?._yt as string) ?? null); setYtSearch((e.state?._yt as string) ?? null);
@ -444,7 +455,7 @@ export default function App() {
if (result === "ok") { if (result === "ok") {
notify({ level: "success", message: t("settings.account.googleLink.linked") }); notify({ level: "success", message: t("settings.account.googleLink.linked") });
void meQuery.refetch(); void meQuery.refetch();
localStorage.setItem(LS.settingsTab, "account"); setAccountRaw(LS.settingsTab, "account");
setPage("settings"); setPage("settings");
} else { } else {
const key = result === "conflict" || result === "mismatch" ? result : "error"; const key = result === "conflict" || result === "mismatch" ? result : "error";
@ -472,7 +483,7 @@ export default function App() {
performanceMode: performanceMode:
typeof prefs.performanceMode === "boolean" typeof prefs.performanceMode === "boolean"
? prefs.performanceMode ? prefs.performanceMode
: localStorage.getItem(PERF_KEY) === "1", : getAccountRaw(PERF_KEY) === "1",
hints: typeof prefs.hints === "boolean" ? prefs.hints : hintsEnabled(), hints: typeof prefs.hints === "boolean" ? prefs.hints : hintsEnabled(),
notifications: prefs.notifications notifications: prefs.notifications
? { ...getNotifSettings(), ...prefs.notifications } ? { ...getNotifSettings(), ...prefs.notifications }
@ -493,11 +504,11 @@ export default function App() {
} }
if (typeof prefs.navCollapsed === "boolean") { if (typeof prefs.navCollapsed === "boolean") {
setNavCollapsedState(prefs.navCollapsed); setNavCollapsedState(prefs.navCollapsed);
writeJSON(LS.navCollapsed, prefs.navCollapsed); writeAccount(LS.navCollapsed, prefs.navCollapsed);
} }
if (typeof prefs.filterCollapsed === "boolean") { if (typeof prefs.filterCollapsed === "boolean") {
setFilterCollapsedState(prefs.filterCollapsed); setFilterCollapsedState(prefs.filterCollapsed);
writeJSON(LS.filterCollapsed, prefs.filterCollapsed); writeAccount(LS.filterCollapsed, prefs.filterCollapsed);
} }
if (isSupported(prefs.language)) setLanguage(prefs.language); if (isSupported(prefs.language)) setLanguage(prefs.language);
// (Per-account filters — incl. the demo account's whole-library default — are loaded by the // (Per-account filters — incl. the demo account's whole-library default — are loaded by the

View file

@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react"; import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
import { api, type AdminUserRow, type Invite, type Me } from "../lib/api"; import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { LS } from "../lib/storage"; import { LS, setAccountRaw } from "../lib/storage";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import { Section } from "./ui/form"; import { Section } from "./ui/form";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
@ -20,7 +20,7 @@ export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab;
export const ADMIN_USERS_ACCESS_TAB = "access"; export const ADMIN_USERS_ACCESS_TAB = "access";
export function focusAccessRequestsTab(): void { export function focusAccessRequestsTab(): void {
localStorage.setItem(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB); setAccountRaw(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB);
} }
export default function AdminUsers({ me }: { me: Me }) { export default function AdminUsers({ me }: { me: Me }) {

View file

@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { UserPlus } from "lucide-react"; import { UserPlus } from "lucide-react";
import { api, HttpError, type DiscoveredChannel } from "../lib/api"; import { api, HttpError, type DiscoveredChannel } from "../lib/api";
import { accountKey } from "../lib/storage";
import { formatViews } from "../lib/format"; import { formatViews } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
@ -173,7 +174,7 @@ export default function ChannelDiscovery({
rows={rows} rows={rows}
columns={columns} columns={columns}
rowKey={(c) => c.id} rowKey={(c) => c.id}
persistKey="siftlode.channelDiscoveryTable" persistKey={accountKey("siftlode.channelDiscoveryTable") ?? undefined}
controlsPosition="top" controlsPosition="top"
emptyText={t("channels.discovery.empty")} emptyText={t("channels.discovery.empty")}
/> />

View file

@ -17,6 +17,7 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api"; import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
import { accountKey } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss"; import { useDismiss } from "../lib/useDismiss";
import { formatEta, formatTotalHours, formatViews, relativeTime } from "../lib/format"; import { formatEta, formatTotalHours, formatViews, relativeTime } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
@ -569,7 +570,7 @@ export default function Channels({
rows={visibleChannels} rows={visibleChannels}
columns={columns} columns={columns}
rowKey={(c) => c.id} rowKey={(c) => c.id}
persistKey="siftlode.channelsTable" persistKey={accountKey("siftlode.channelsTable") ?? undefined}
controlsPosition="top" controlsPosition="top"
controlsLeading={statusChips} controlsLeading={statusChips}
rowClassName={(c) => (c.hidden ? "opacity-60" : "")} rowClassName={(c) => (c.hidden ? "opacity-60" : "")}

View file

@ -37,7 +37,7 @@ import {
import { api, type Playlist, type Video } from "../lib/api"; import { api, type Playlist, type Video } from "../lib/api";
import { formatDuration } from "../lib/format"; import { formatDuration } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { LS, readMerged, writeJSON } from "../lib/storage"; import { getAccountRaw, LS, readAccountMerged, setAccountRaw, writeAccount } from "../lib/storage";
import { useUndoable } from "../lib/useUndoable"; import { useUndoable } from "../lib/useUndoable";
import PlayerModal from "./PlayerModal"; import PlayerModal from "./PlayerModal";
import UndoToolbar from "./UndoToolbar"; import UndoToolbar from "./UndoToolbar";
@ -188,18 +188,18 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
const confirm = useConfirm(); const confirm = useConfirm();
// Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL). // Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL).
const [selectedId, setSelectedId] = useState<number | null>(() => { const [selectedId, setSelectedId] = useState<number | null>(() => {
const s = localStorage.getItem(LS.playlist); const s = getAccountRaw(LS.playlist);
return s ? Number(s) : null; return s ? Number(s) : null;
}); });
useEffect(() => { useEffect(() => {
if (selectedId != null) localStorage.setItem(LS.playlist, String(selectedId)); if (selectedId != null) setAccountRaw(LS.playlist, String(selectedId));
}, [selectedId]); }, [selectedId]);
const selectedRef = useRef<HTMLButtonElement | null>(null); const selectedRef = useRef<HTMLButtonElement | null>(null);
const scrolledRef = useRef(false); const scrolledRef = useRef(false);
// How the left playlist rail is ordered (persisted). // How the left playlist rail is ordered (persisted).
const [plSort, setPlSort] = useState<PlSort>(() => readMerged(LS.plSort, PL_SORT_DEFAULT)); const [plSort, setPlSort] = useState<PlSort>(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT));
useEffect(() => { useEffect(() => {
writeJSON(LS.plSort, plSort); writeAccount(LS.plSort, plSort);
}, [plSort]); }, [plSort]);
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);

View file

@ -4,7 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { Bell, Monitor, Trash2, User } from "lucide-react"; import { Bell, Monitor, Trash2, User } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, type Me } from "../lib/api"; import { api, type Me } from "../lib/api";
import { LS, usePersistedState } from "../lib/storage"; import { LS, useAccountPersistedState } from "../lib/storage";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications"; import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
@ -54,7 +54,7 @@ export default function SettingsPanel({
onOpenWizard: () => void; onOpenWizard: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [tabRaw, setTab] = usePersistedState(LS.settingsTab, "appearance"); const [tabRaw, setTab] = useAccountPersistedState(LS.settingsTab, "appearance");
// Clamp at render so a stale/removed tab id falls back to Appearance. // Clamp at render so a stale/removed tab id falls back to Appearance.
const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw) const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw)
? (tabRaw as TabId) ? (tabRaw as TabId)

View file

@ -5,7 +5,7 @@ import { History, Pause, Play, RefreshCw } from "lucide-react";
import { api, type AdminQuotaRow, type Me } from "../lib/api"; import { api, type AdminQuotaRow, type Me } from "../lib/api";
import { formatEta, quotaActionLabel } from "../lib/format"; import { formatEta, quotaActionLabel } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { LS, usePersistedState } from "../lib/storage"; import { LS, useAccountPersistedState } from "../lib/storage";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import { Section, SettingRow } from "./ui/form"; import { Section, SettingRow } from "./ui/form";
@ -18,7 +18,7 @@ type StatsTab = "overview" | "system";
export default function Stats({ me }: { me: Me }) { export default function Stats({ me }: { me: Me }) {
const { t } = useTranslation(); const { t } = useTranslation();
const isAdmin = me.role === "admin"; const isAdmin = me.role === "admin";
const [tabRaw, setTab] = usePersistedState(LS.statsTab, "overview"); const [tabRaw, setTab] = useAccountPersistedState(LS.statsTab, "overview");
// Clamp at render: only admins may land on the System tab (covers a stale stored value). // Clamp at render: only admins may land on the System tab (covers a stale stored value).
const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview"; const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview";

View file

@ -1,4 +1,4 @@
import { usePersistedState } from "../lib/storage"; import { useAccountPersistedState } from "../lib/storage";
// Reusable horizontal pill tab-rail for in-page sub-navigation (Configuration, Users & roles…). // Reusable horizontal pill tab-rail for in-page sub-navigation (Configuration, Users & roles…).
// The active tab is persisted to localStorage so a reload (F5) keeps the user where they were, // The active tab is persisted to localStorage so a reload (F5) keeps the user where they were,
@ -10,9 +10,9 @@ export interface TabDef {
badge?: number; // optional count pill (e.g. pending requests); hidden when 0/undefined badge?: number; // optional count pill (e.g. pending requests); hidden when 0/undefined
} }
/** Persisted active-tab state the canonical helper now lives in lib/storage as /** Persisted active-tab state, scoped to the current account (so one account's last tab doesn't
* `usePersistedState`; re-exported here under its original name for existing call sites. */ * carry into another in the same browser) re-exported under its original name for call sites. */
export const usePersistedTab = usePersistedState; export const usePersistedTab = useAccountPersistedState;
export default function Tabs({ export default function Tabs({
tabs, tabs,

View file

@ -1,6 +1,7 @@
import { notify, remove } from "./notifications"; import { notify, remove } from "./notifications";
import i18n from "../i18n"; import i18n from "../i18n";
import { reportError } from "./errorDialog"; import { reportError } from "./errorDialog";
import { activeAccountId, ACTIVE_ACCOUNT_KEY } from "./storage";
export interface Me { export interface Me {
id: number; id: number;
@ -270,19 +271,10 @@ const setupHeaders = (token: string) => ({
// The signed session cookie is the browser's account "wallet"; which account a given TAB acts // The signed session cookie is the browser's account "wallet"; which account a given TAB acts
// as is chosen here and sent per-request, so two tabs can run two accounts at once. Stored in // as is chosen here and sent per-request, so two tabs can run two accounts at once. Stored in
// sessionStorage (per-tab, survives F5, not shared across tabs). null = use the session default. // sessionStorage (per-tab, survives F5, not shared across tabs). null = use the session default.
// The storage key + reader live in storage.ts (so its per-account helpers can use them too).
const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account"; const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account";
const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
export function getActiveAccount(): number | null { export const getActiveAccount = activeAccountId;
try {
const raw = sessionStorage.getItem(ACTIVE_ACCOUNT_KEY);
if (!raw) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
} catch {
return null;
}
}
export function setActiveAccount(id: number): void { export function setActiveAccount(id: number): void {
try { try {
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id)); sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id));

View file

@ -2,14 +2,10 @@
// captions on hover so the UI is somewhat self-documenting for new users. Experienced // captions on hover so the UI is somewhat self-documenting for new users. Experienced
// users can turn it off in Settings. Persisted to localStorage (+ server preferences). // users can turn it off in Settings. Persisted to localStorage (+ server preferences).
import { createStore } from "./store"; import { createStore } from "./store";
import { LS } from "./storage"; import { getAccountRaw, LS, setAccountRaw } from "./storage";
function load(): boolean { function load(): boolean {
try { return getAccountRaw(LS.hints) !== "0"; // default ON (also when the account isn't known yet)
return localStorage.getItem(LS.hints) !== "0"; // default ON
} catch {
return true;
}
} }
const store = createStore<boolean>(load()); const store = createStore<boolean>(load());
@ -19,9 +15,5 @@ export const subscribeHints = store.subscribe;
export function setHintsEnabled(v: boolean): void { export function setHintsEnabled(v: boolean): void {
store.set(v); store.set(v);
try { setAccountRaw(LS.hints, v ? "1" : "0");
localStorage.setItem(LS.hints, v ? "1" : "0");
} catch {
/* ignore */
}
} }

View file

@ -2,7 +2,7 @@
// the Notification Center shows (info events, actions awaiting interaction, and app // the Notification Center shows (info events, actions awaiting interaction, and app
// errors). History is persisted to localStorage so it survives reloads; action // errors). History is persisted to localStorage so it survives reloads; action
// callbacks are live-only and dropped on reload. // callbacks are live-only and dropped on reload.
import { LS, readJSON, readMerged, writeJSON } from "./storage"; import { LS, readAccount, readAccountMerged, writeAccount } from "./storage";
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal"; export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
@ -87,7 +87,7 @@ const DEFAULT_SETTINGS: NotifSettings = { sound: false, durationMs: 6000 };
let config: NotifSettings = loadSettings(); let config: NotifSettings = loadSettings();
function loadSettings(): NotifSettings { function loadSettings(): NotifSettings {
return readMerged(SETTINGS_KEY, DEFAULT_SETTINGS); return readAccountMerged(SETTINGS_KEY, DEFAULT_SETTINGS);
} }
export function getNotifSettings(): NotifSettings { export function getNotifSettings(): NotifSettings {
@ -96,7 +96,7 @@ export function getNotifSettings(): NotifSettings {
export function configureNotifications(p: Partial<NotifSettings>): void { export function configureNotifications(p: Partial<NotifSettings>): void {
config = { ...config, ...p }; config = { ...config, ...p };
writeJSON(SETTINGS_KEY, config); writeAccount(SETTINGS_KEY, config);
} }
let audioCtx: AudioContext | null = null; let audioCtx: AudioContext | null = null;
@ -130,7 +130,7 @@ let cachedUnread = 0;
function load(): Notification[] { function load(): Notification[] {
try { try {
const raw = readJSON<unknown>(HISTORY_KEY, []); const raw = readAccount<unknown>(HISTORY_KEY, []);
if (!Array.isArray(raw)) return []; if (!Array.isArray(raw)) return [];
// Restored entries are history only. Mark them dismissed so they don't // Restored entries are history only. Mark them dismissed so they don't
// resurrect as active toasts on reload — their auto-dismiss timers aren't // resurrect as active toasts on reload — their auto-dismiss timers aren't
@ -147,7 +147,7 @@ function persist() {
// Transient status notices are live-session only — never write them to history, // Transient status notices are live-session only — never write them to history,
// so a reload can't leave one stranded with no producer left to clear it. // so a reload can't leave one stranded with no producer left to clear it.
const slim = items.filter((n) => !n.transient).map(({ action: _action, ...n }) => n); const slim = items.filter((n) => !n.transient).map(({ action: _action, ...n }) => n);
writeJSON(HISTORY_KEY, slim); writeAccount(HISTORY_KEY, slim);
} catch { } catch {
/* ignore quota / serialization errors */ /* ignore quota / serialization errors */
} }

View file

@ -12,9 +12,11 @@
// suppresses the auto-popup on future logins (they // suppresses the auto-popup on future logins (they
// can still reopen it from Settings → Account). // can still reopen it from Settings → Account).
import { LS } from "./storage"; import { getAccountRaw, LS, setAccountRaw } from "./storage";
export const ONBOARD_ACTIVE = "siftlode.onboarding.active"; // sessionStorage (not in LS, which is localStorage) export const ONBOARD_ACTIVE = "siftlode.onboarding.active"; // sessionStorage (not in LS, which is localStorage)
// Per-account (see accountKey): a fresh account should still get the onboarding nudge even if a
// different account in the same browser dismissed it.
export const ONBOARD_DISMISSED = LS.onboardingDismissed; export const ONBOARD_DISMISSED = LS.onboardingDismissed;
export function shouldAutoOpenOnboarding(me: { export function shouldAutoOpenOnboarding(me: {
@ -28,7 +30,7 @@ export function shouldAutoOpenOnboarding(me: {
// email+password only — then there's nothing to connect, so don't nudge. // email+password only — then there's nothing to connect, so don't nudge.
if (me.google_enabled === false) return false; if (me.google_enabled === false) return false;
if (sessionStorage.getItem(ONBOARD_ACTIVE)) return true; if (sessionStorage.getItem(ONBOARD_ACTIVE)) return true;
return !me.can_read && !localStorage.getItem(ONBOARD_DISMISSED); return !me.can_read && !getAccountRaw(ONBOARD_DISMISSED);
} }
/** Mark a grant as in progress, then send the user to Google's consent screen. */ /** Mark a grant as in progress, then send the user to Google's consent screen. */
@ -40,5 +42,5 @@ export function beginGrant(access: "read" | "write"): void {
/** Leave the wizard. `dismiss` suppresses the future auto-popup (used when read is skipped). */ /** Leave the wizard. `dismiss` suppresses the future auto-popup (used when read is skipped). */
export function endOnboarding(dismiss: boolean): void { export function endOnboarding(dismiss: boolean): void {
sessionStorage.removeItem(ONBOARD_ACTIVE); sessionStorage.removeItem(ONBOARD_ACTIVE);
if (dismiss) localStorage.setItem(ONBOARD_DISMISSED, "1"); if (dismiss) setAccountRaw(ONBOARD_DISMISSED, "1");
} }

View file

@ -4,7 +4,7 @@
// `show`, `sort` and `content` moved to the feed toolbar (above the cards); they are no // `show`, `sort` and `content` moved to the feed toolbar (above the cards); they are no
// longer sidebar widgets. normalizeLayout drops them from any persisted layout automatically. // longer sidebar widgets. normalizeLayout drops them from any persisted layout automatically.
import { LS, readJSON, writeJSON } from "./storage"; import { LS, readAccount, writeAccount } from "./storage";
export type WidgetId = "savedviews" | "date" | "language" | "topic" | "tags"; export type WidgetId = "savedviews" | "date" | "language" | "topic" | "tags";
@ -46,9 +46,9 @@ export function normalizeLayout(raw: unknown): SidebarLayout {
} }
export function loadLayout(): SidebarLayout { export function loadLayout(): SidebarLayout {
return normalizeLayout(readJSON<unknown>(LS.sidebarLayout, {})); return normalizeLayout(readAccount<unknown>(LS.sidebarLayout, {}));
} }
export function saveLayoutLocal(l: SidebarLayout): void { export function saveLayoutLocal(l: SidebarLayout): void {
writeJSON(LS.sidebarLayout, l); writeAccount(LS.sidebarLayout, l);
} }

View file

@ -30,17 +30,64 @@ export const LS = {
chatDock: (userId: number) => `siftlode.chatDock.${userId}`, chatDock: (userId: number) => `siftlode.chatDock.${userId}`,
} as const; } as const;
// --- typed read/write helpers (do the JSON + try/catch once) ---------------------------- // --- per-account scoping -----------------------------------------------------------------
// The account a browser TAB is acting as (per-tab, in sessionStorage — see api.ts). Duplicated
// here (rather than imported from api.ts) so storage.ts stays dependency-free and usable by the
// low-level helpers below.
export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
/** Suffix a base localStorage key with an account id. Per-account UI state (the feed filters and export function activeAccountId(): number | null {
* the default-view mirror) must NOT be shared across the accounts signed into one browser a try {
* bare shared key leaks one account's view into another (and, with per-tab accounts, two tabs const raw = sessionStorage.getItem(ACTIVE_ACCOUNT_KEY);
* would fight over it). Returns null when the account isn't known yet, so callers fall back to if (!raw) return null;
* defaults / skip persistence instead of touching another account's data. */ const n = Number(raw);
export function accountKey(base: string, id: number | null | undefined): string | null { return Number.isFinite(n) ? n : null;
} catch {
return null;
}
}
/** Suffix a base localStorage key with an account id so per-account UI state (filters, selected
* playlist, notifications, tab positions, cached prefs) is NOT shared across the accounts signed
* into one browser a bare shared key leaks one account's state into another (and, with per-tab
* accounts, two tabs would fight over it). Defaults to the current tab's active account; returns
* null when no account is 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 = activeAccountId()): string | null {
return id != null ? `${base}.${id}` : null; return id != null ? `${base}.${id}` : null;
} }
/** Per-account variants of the read/write helpers: key by the current account, no-op / return the
* fallback when the account isn't known (avoids reading or writing a shared/other-account key). */
export function readAccount<T>(base: string, fallback: T): T {
const k = accountKey(base);
return k ? readJSON(k, fallback) : fallback;
}
export function readAccountMerged<T extends object>(base: string, defaults: T): T {
const k = accountKey(base);
return k ? readMerged(k, defaults) : { ...defaults };
}
export function writeAccount(base: string, value: unknown): void {
const k = accountKey(base);
if (k) writeJSON(k, value);
}
export function getAccountRaw(base: string): string | null {
const k = accountKey(base);
return k ? localStorage.getItem(k) : null;
}
export function setAccountRaw(base: string, value: string): void {
const k = accountKey(base);
if (k) {
try {
localStorage.setItem(k, value);
} catch {
/* ignore */
}
}
}
// --- typed read/write helpers (do the JSON + try/catch once) ----------------------------
/** 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 {
@ -91,3 +138,18 @@ export function usePersistedState(
}; };
return [value, set]; return [value, set];
} }
/** Like usePersistedState but scoped to the current account (see accountKey), so one account's
* persisted tab/view position doesn't carry over to another in the same browser. These hooks run
* in components that only mount once signed in, so the account is known. */
export function useAccountPersistedState(
base: string,
fallback = "",
): [string, (value: string) => void] {
const [value, setValue] = useState<string>(() => getAccountRaw(base) ?? fallback);
const set = (next: string) => {
setValue(next);
setAccountRaw(base, next);
};
return [value, set];
}

View file

@ -1,4 +1,4 @@
import { LS, readMerged, writeJSON } from "./storage"; import { LS, readAccountMerged, writeAccount } from "./storage";
export type Mode = "dark" | "light"; export type Mode = "dark" | "light";
export type Scheme = "midnight" | "forest" | "slate" | "youtube"; export type Scheme = "midnight" | "forest" | "slate" | "youtube";
@ -30,9 +30,9 @@ export function applyTheme(t: ThemePrefs): void {
} }
export function loadLocalTheme(): ThemePrefs { export function loadLocalTheme(): ThemePrefs {
return readMerged(LS.theme, DEFAULT_THEME); return readAccountMerged(LS.theme, DEFAULT_THEME);
} }
export function saveLocalTheme(t: ThemePrefs): void { export function saveLocalTheme(t: ThemePrefs): void {
writeJSON(LS.theme, t); writeAccount(LS.theme, t);
} }