import {createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState} from 'react'; import {useTranslation} from 'react-i18next'; import { applyAppearance, cacheAppearance, DEFAULT_APPEARANCE, loadPreferences, normalizeAppearance, readCachedAppearance, saveAppearance, } from '../utils/appearance.js'; import {hasSession} from '../utils/auth.js'; import {LANGUAGES, saveSettings} from '../utils/settings.js'; const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode'; // The --background of style.css; the installed app paints its title bar in this color. const THEME_COLORS = {dark: '#09090b', light: '#fafafa'}; const PreferencesContext = createContext(null); export function PreferencesProvider({children}) { const {i18n} = useTranslation(); const [isDarkMode, setIsDarkMode] = useState(() => { const stored = localStorage.getItem(DARK_MODE_STORAGE_KEY); return stored === null ? true : stored === 'true'; }); useEffect(() => { if (isDarkMode) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } document.querySelector('meta[name="theme-color"]') ?.setAttribute('content', isDarkMode ? THEME_COLORS.dark : THEME_COLORS.light); localStorage.setItem(DARK_MODE_STORAGE_KEY, String(isDarkMode)); }, [isDarkMode]); const [appearance, setAppearanceState] = useState(readCachedAppearance); // What Auth has, as far as this tab knows. A save sends it plus the pending changes, never a stale full look. const savedAppearance = useRef(appearance); // Changed here but not yet saved; a load from Auth keeps them. const pendingChanges = useRef({}); const syncing = useRef(Promise.resolve()); const saving = useRef(Promise.resolve()); // Grows on reset, so a load or save that started for the user before does not come back. const generation = useRef(0); // Layout effect: the cached look is set before the first paint. useLayoutEffect(() => { applyAppearance(appearance); cacheAppearance(appearance); }, [appearance]); // Grows on every language pick, so a load that started before does not undo it. const languagePicks = useRef(0); const syncPreferences = useCallback(() => { const started = generation.current; const picksBefore = languagePicks.current; syncing.current = (async () => { try { const loaded = await loadPreferences(); if (loaded?.appearance && generation.current === started) { savedAppearance.current = loaded.appearance; setAppearanceState(normalizeAppearance({...loaded.appearance, ...pendingChanges.current})); } if (LANGUAGES.includes(loaded?.language) && languagePicks.current === picksBefore && loaded.language !== i18n.language) { i18n.changeLanguage(loaded.language); } } catch (err) { console.error('Preferences load error:', err); } })(); return syncing.current; }, [i18n]); /** * Applies the changed fields at once, then saves them to Auth after a running load and after the saves before * it, one at a time, so answers cannot arrive out of order. On a failed save it puts back the fields that still * hold this change, so a later change is kept. * * @return the fetch response, or null when a reset came first and nothing was sent; throws on a network error */ const changeAppearance = useCallback((changes) => { const started = generation.current; pendingChanges.current = {...pendingChanges.current, ...changes}; setAppearanceState((current) => normalizeAppearance({...current, ...changes})); const save = async () => { await syncing.current; if (generation.current !== started) return null; const body = normalizeAppearance({...savedAppearance.current, ...pendingChanges.current}); let response = null; try { response = await saveAppearance(body); return response; } finally { if (generation.current === started) { const ok = !!response?.ok; if (ok) savedAppearance.current = body; const own = Object.keys(changes).filter((field) => pendingChanges.current[field] === changes[field]); own.forEach((field) => delete pendingChanges.current[field]); if (!ok && own.length) { setAppearanceState((current) => normalizeAppearance({ ...current, ...Object.fromEntries(own.map((field) => [field, savedAppearance.current[field]])), })); } } } }; const result = saving.current.then(save, save); saving.current = result.catch(() => {}); return result; }, []); const resetAppearance = useCallback(() => { generation.current += 1; pendingChanges.current = {}; savedAppearance.current = DEFAULT_APPEARANCE; setAppearanceState(DEFAULT_APPEARANCE); }, []); /** * Applies the language at once and saves it to Auth while logged in. * * @return the fetch response, or null when logged out; rejects on a network error */ const setLanguage = (lang) => { languagePicks.current += 1; i18n.changeLanguage(lang); if (!hasSession()) return Promise.resolve(null); return saveSettings({language: lang}); }; return ( {children} ); } export function usePreferences() { const ctx = useContext(PreferencesContext); if (!ctx) { throw new Error('usePreferences must be used inside '); } return ctx; } /** * resetAppearance, or a no-op outside a PreferencesProvider, so hooks used in bare component tests keep working. */ export function useResetAppearance() { return useContext(PreferencesContext)?.resetAppearance ?? noop; } const noop = () => {};