Pref context

This commit is contained in:
binwiederhier
2026-06-26 06:20:58 -04:00
parent af66cc3ddc
commit 9114d1e996
5 changed files with 71 additions and 64 deletions
+15 -5
View File
@@ -10,6 +10,16 @@ export const THEME = {
// index.html reads it synchronously to pick the splash background before first paint.
export const THEME_LOCALSTORAGE_KEY = "theme";
// Default values returned by the getters below when a preference hasn't been set. Single source of
// truth, also used by the PrefCache (see PrefCache.jsx) for its loading-state placeholder.
export const PREF_DEFAULTS = {
sound: "ding",
minPriority: 1,
deleteAfter: 604800, // one week
theme: THEME.SYSTEM,
webPushEnabled: false,
};
const mirrorThemeToLocalStorage = (value) => {
try {
localStorage.setItem(THEME_LOCALSTORAGE_KEY, value);
@@ -30,7 +40,7 @@ class Prefs {
async sound() {
const sound = await this.db.prefs.get("sound");
return sound ? sound.value : "ding";
return sound ? sound.value : PREF_DEFAULTS.sound;
}
async setMinPriority(minPriority) {
@@ -39,7 +49,7 @@ class Prefs {
async minPriority() {
const minPriority = await this.db.prefs.get("minPriority");
return minPriority ? Number(minPriority.value) : 1;
return minPriority ? Number(minPriority.value) : PREF_DEFAULTS.minPriority;
}
async setDeleteAfter(deleteAfter) {
@@ -48,12 +58,12 @@ class Prefs {
async deleteAfter() {
const deleteAfter = await this.db.prefs.get("deleteAfter");
return deleteAfter ? Number(deleteAfter.value) : 604800; // Default is one week
return deleteAfter ? Number(deleteAfter.value) : PREF_DEFAULTS.deleteAfter;
}
async webPushEnabled() {
const webPushEnabled = await this.db.prefs.get("webPushEnabled");
return webPushEnabled?.value;
return webPushEnabled?.value ?? PREF_DEFAULTS.webPushEnabled;
}
async setWebPushEnabled(enabled) {
@@ -62,7 +72,7 @@ class Prefs {
async theme() {
const theme = await this.db.prefs.get("theme");
const value = theme?.value ?? THEME.SYSTEM;
const value = theme?.value ?? PREF_DEFAULTS.theme;
// Mirror to localStorage so the inline script in index.html can pick the splash background
// synchronously before first paint. Self-heals for users who set their theme before the
// mirror existed.
+3
View File
@@ -28,6 +28,7 @@ import prefs from "../app/Prefs";
import RTLCacheProvider from "./RTLCacheProvider";
import session from "../app/Session";
import AccountContext from "./AccountContext";
import { PrefCacheProvider } from "./PrefCache";
import hideSplash from "../app/splash";
initI18n();
@@ -148,6 +149,7 @@ const Layout = () => {
}, [subscriptions]);
return (
<PrefCacheProvider>
<Box sx={{ display: "flex" }}>
<ActionBar selected={selected} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} />
<Navigation
@@ -169,6 +171,7 @@ const Layout = () => {
</Main>
<Messaging selected={selected} dialogOpenMode={sendDialogOpenMode} onDialogOpenModeChange={setSendDialogOpenMode} />
</Box>
</PrefCacheProvider>
);
};
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react";
import { createContext, useContext } from "react";
import { useLiveQuery } from "dexie-react-hooks";
import prefs, { PREF_DEFAULTS } from "../app/Prefs";
// A render-time CACHE of the user's preferences -- NOT the source of truth. The source of truth is
// the `prefs` table in IndexedDB, always read/written via Prefs.js. This context preloads all prefs
// once (mounted by Layout, above the routed content), so the Settings page renders its controls
// with the right values immediately instead of each one flickering in as its own IndexedDB read
// resolves. By the time you navigate to Settings the query has already resolved.
const PrefCacheContext = createContext(undefined);
export const PrefCacheProvider = ({ children }) => {
const cache = useLiveQuery(async () => ({
sound: await prefs.sound(),
minPriority: await prefs.minPriority(),
deleteAfter: await prefs.deleteAfter(),
theme: await prefs.theme(),
webPushEnabled: await prefs.webPushEnabled(),
}));
return <PrefCacheContext.Provider value={cache}>{children}</PrefCacheContext.Provider>;
};
// While the cache is still loading (only on the very first paint, mostly hidden by the splash) fall
// back to the same defaults the Prefs getters use, so controls never render with no value.
export const usePrefCache = () => useContext(PrefCacheContext) ?? PREF_DEFAULTS;
+7 -6
View File
@@ -49,7 +49,8 @@ import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./Rese
import { UnauthorizedError } from "../app/errors";
import { subscribeTopic } from "./SubscribeDialog";
import notifier from "../app/Notifier";
import { useCachedPref, useIsLaunchedPWA, useNotificationPermissionListener } from "./hooks";
import { useIsLaunchedPWA, useNotificationPermissionListener } from "./hooks";
import { usePrefCache } from "./PrefCache";
const maybeUpdateAccountSettings = async (payload) => {
if (!session.exists()) {
@@ -99,7 +100,7 @@ const Notifications = () => {
const Sound = () => {
const { t } = useTranslation();
const labelId = "prefSound";
const sound = useCachedPref(() => prefs.sound(), "pref.sound", "ding");
const { sound } = usePrefCache();
const handleChange = async (ev) => {
await prefs.setSound(ev.target.value);
await maybeUpdateAccountSettings({
@@ -140,7 +141,7 @@ const Sound = () => {
const MinPriority = () => {
const { t } = useTranslation();
const labelId = "prefMinPriority";
const minPriority = useCachedPref(() => prefs.minPriority(), "pref.minPriority", 1);
const { minPriority } = usePrefCache();
const handleChange = async (ev) => {
await prefs.setMinPriority(ev.target.value);
await maybeUpdateAccountSettings({
@@ -185,7 +186,7 @@ const MinPriority = () => {
const DeleteAfter = () => {
const { t } = useTranslation();
const labelId = "prefDeleteAfter";
const deleteAfter = useCachedPref(() => prefs.deleteAfter(), "pref.deleteAfter", 604800);
const { deleteAfter } = usePrefCache();
const handleChange = async (ev) => {
await prefs.setDeleteAfter(ev.target.value);
await maybeUpdateAccountSettings({
@@ -230,7 +231,7 @@ const DeleteAfter = () => {
const Theme = () => {
const { t } = useTranslation();
const labelId = "prefTheme";
const theme = useCachedPref(() => prefs.theme(), "pref.theme", THEME.SYSTEM);
const { theme } = usePrefCache();
const handleChange = async (ev) => {
await prefs.setTheme(ev.target.value);
};
@@ -251,7 +252,7 @@ const Theme = () => {
const WebPushEnabled = () => {
const { t } = useTranslation();
const labelId = "prefWebPushEnabled";
const enabled = useCachedPref(() => prefs.webPushEnabled(), "pref.webPushEnabled", false);
const { webPushEnabled: enabled } = usePrefCache();
const handleChange = async (ev) => {
await prefs.setWebPushEnabled(ev.target.value);
};
-33
View File
@@ -15,39 +15,6 @@ import notifier from "../app/Notifier";
import prefs from "../app/Prefs";
import { EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR, SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG } from "../app/events";
/**
* Reads a preference via a Dexie live query, but renders the last known value from localStorage
* synchronously on first paint (and mirrors new values back to localStorage). This avoids the
* Settings page flicker where rows "pop in" one by one while IndexedDB resolves -- the cached value
* shows instantly and is confirmed/updated from IndexedDB in the background.
*/
export const useCachedPref = (reader, cacheKey, fallback) => {
const live = useLiveQuery(reader);
useEffect(() => {
if (live !== undefined && live !== null) {
try {
localStorage.setItem(cacheKey, JSON.stringify(live));
} catch (e) {
// localStorage may be unavailable (private mode); the value just isn't cached for next time
}
}
}, [live, cacheKey]);
if (live !== undefined && live !== null) {
return live;
}
try {
const cached = localStorage.getItem(cacheKey);
if (cached !== null) {
return JSON.parse(cached);
}
} catch (e) {
// ignore parse/storage errors and fall back
}
return fallback;
};
/**
* Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
* state changes. Conversely, when the subscription changes, the connection is refreshed (which may lead