import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; export type ToastTone = "info" | "success" | "warn" | "error"; export interface ToastAction { label: string; href: string; } export interface ToastInput { id?: string; dedupeKey?: string; title: string; body?: string; tone?: ToastTone; ttlMs?: number; action?: ToastAction; } export interface ToastItem { id: string; title: string; body?: string; tone: ToastTone; ttlMs: number; action?: ToastAction; createdAt: number; } interface ToastContextValue { toasts: ToastItem[]; pushToast: (input: ToastInput) => string | null; dismissToast: (id: string) => void; clearToasts: () => void; } const DEFAULT_TTL_BY_TONE: Record = { info: 4000, success: 3500, warn: 8000, error: 10000, }; const MIN_TTL_MS = 1500; const MAX_TTL_MS = 15000; const MAX_TOASTS = 5; const DEDUPE_WINDOW_MS = 3500; const DEDUPE_MAX_AGE_MS = 20000; const ToastContext = createContext(null); function normalizeTtl(value: number | undefined, tone: ToastTone) { const fallback = DEFAULT_TTL_BY_TONE[tone]; if (typeof value !== "number" || !Number.isFinite(value)) return fallback; return Math.max(MIN_TTL_MS, Math.min(MAX_TTL_MS, Math.floor(value))); } function generateToastId() { return `toast_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; } export function ToastProvider({ children }: { children: ReactNode }) { const [toasts, setToasts] = useState([]); const timersRef = useRef(new Map()); const dedupeRef = useRef(new Map()); const clearTimer = useCallback((id: string) => { const handle = timersRef.current.get(id); if (handle !== undefined) { window.clearTimeout(handle); timersRef.current.delete(id); } }, []); const dismissToast = useCallback( (id: string) => { clearTimer(id); setToasts((prev) => prev.filter((toast) => toast.id !== id)); }, [clearTimer], ); const clearToasts = useCallback(() => { for (const handle of timersRef.current.values()) { window.clearTimeout(handle); } timersRef.current.clear(); setToasts([]); }, []); const pushToast = useCallback( (input: ToastInput) => { const now = Date.now(); const tone = input.tone ?? "info"; const ttlMs = normalizeTtl(input.ttlMs, tone); const dedupeKey = input.dedupeKey ?? input.id ?? `${tone}|${input.title}|${input.body ?? ""}|${input.action?.href ?? ""}`; for (const [key, ts] of dedupeRef.current.entries()) { if (now - ts > DEDUPE_MAX_AGE_MS) { dedupeRef.current.delete(key); } } const lastSeen = dedupeRef.current.get(dedupeKey); if (lastSeen && now - lastSeen < DEDUPE_WINDOW_MS) { return null; } dedupeRef.current.set(dedupeKey, now); const id = input.id ?? generateToastId(); clearTimer(id); setToasts((prev) => { const nextToast: ToastItem = { id, title: input.title, body: input.body, tone, ttlMs, action: input.action, createdAt: now, }; const withoutCurrent = prev.filter((toast) => toast.id !== id); return [nextToast, ...withoutCurrent].slice(0, MAX_TOASTS); }); const timeout = window.setTimeout(() => { dismissToast(id); }, ttlMs); timersRef.current.set(id, timeout); return id; }, [clearTimer, dismissToast], ); useEffect(() => () => { for (const handle of timersRef.current.values()) { window.clearTimeout(handle); } timersRef.current.clear(); }, []); const value = useMemo( () => ({ toasts, pushToast, dismissToast, clearToasts, }), [toasts, pushToast, dismissToast, clearToasts], ); return {children}; } export function useToast() { const context = useContext(ToastContext); if (!context) { throw new Error("useToast must be used within a ToastProvider"); } return context; }