import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react'; import { ApiError } from '../api/client'; import { useI18n } from '../i18n'; export type ToastTone = 'info' | 'error' | 'success'; export interface Toast { id: number; tone: ToastTone; message: string; /** A single inline action, e.g. "Undo". */ action?: { label: string; run: () => void }; } interface ToastApi { toasts: Toast[]; push(message: string, tone?: ToastTone, action?: Toast['action']): void; /** Report a failed operation in the user's terms. */ fail(error: unknown, fallback?: string): void; dismiss(id: number): void; } const ToastContext = createContext(null); const LIFETIME: Record = { info: 4000, success: 3000, error: 9000 }; export function ToastProvider({ children }: { children: ReactNode }): ReactNode { const [toasts, setToasts] = useState([]); const nextId = useRef(1); const dismiss = useCallback((id: number) => { setToasts((list) => list.filter((t) => t.id !== id)); }, []); const push = useCallback( (message, tone = 'info', action) => { const id = nextId.current++; setToasts((list) => [...list.slice(-3), { id, tone, message, action }]); // A toast that asks for a decision must live long enough to make one. const lifetime = action ? Math.max(LIFETIME[tone], LIFETIME.error) : LIFETIME[tone]; window.setTimeout(() => dismiss(id), lifetime); }, [dismiss], ); const fail = useCallback( (error, fallback = 'That did not work.') => { // Server messages are written for people; prefer them to a generic line. const message = error instanceof ApiError ? error.message : error instanceof Error ? error.message : fallback; push(message, 'error'); }, [push], ); const value = useMemo(() => ({ toasts, push, fail, dismiss }), [toasts, push, fail, dismiss]); return {children}; } export function useToasts(): ToastApi { const ctx = useContext(ToastContext); if (!ctx) throw new Error('useToasts must be used inside a ToastProvider.'); return ctx; } export function Toasts(): ReactNode { const { toasts, dismiss } = useToasts(); // Rendered inside the I18nProvider even though the ToastProvider sits above // it: the provider stores messages, this component is what speaks. const { t } = useI18n(); if (!toasts.length) return null; return (
{toasts.map((toast) => (
{toast.message} {toast.action && ( )}
))}
); }