// App-wide toasts: short messages at the bottom of the screen, optionally with one // action button (usually "Undo"). Used instead of alert()/confirm() so a slip is // reversible and nothing blocks the page. import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { themeVars } from './reader/readerThemes.js'; const ToastContext = createContext(null); // toast(message, { action: { label, onClick }, ms, tone: 'info' | 'error' }) -> id // Outside a provider (a page rendered on its own in a test) it quietly does nothing. const NOOP = { toast: () => 0, dismiss: () => {}, setTheme: () => {} }; export const useToast = () => useContext(ToastContext) ?? NOOP; let nextId = 1; export function ToastProvider({ children }) { const [toasts, setToasts] = useState([]); const [theme, setTheme] = useState('paper'); // set by the app so toasts match the reading theme const timers = useRef(new Map()); const dismiss = useCallback((id) => { window.clearTimeout(timers.current.get(id)); timers.current.delete(id); setToasts((list) => list.filter((t) => t.id !== id)); }, []); const toast = useCallback((message, { action, ms, tone = 'info' } = {}) => { const id = nextId++; // Messages with an action stay long enough to read and react to. const duration = ms ?? (action ? 8000 : tone === 'error' ? 6000 : 3500); setToasts((list) => [...list.slice(-2), { id, message, action, tone }]); timers.current.set(id, window.setTimeout(() => dismiss(id), duration)); return id; }, [dismiss]); useEffect(() => { const live = timers.current; return () => live.forEach((t) => window.clearTimeout(t)); }, []); const value = useMemo(() => ({ toast, dismiss, setTheme }), [toast, dismiss]); return ( {children}
{toasts.map((t) => (
{t.message} {t.action && ( )}
))}
); }