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 = () => {};