"use client"; import { Toast } from "@base-ui/react/toast"; import { useEffect, type CSSProperties } from "react"; import { useParams } from "@tanstack/react-router"; import { ThreadId } from "@t3tools/contracts"; import { CircleAlertIcon, CircleCheckIcon, InfoIcon, LoaderCircleIcon, TriangleAlertIcon, } from "lucide-react"; import { cn } from "~/lib/utils"; import { buttonVariants } from "~/components/ui/button"; import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic"; type ThreadToastData = { threadId?: ThreadId | null; tooltipStyle?: boolean; dismissAfterVisibleMs?: number; }; const toastManager = Toast.createToastManager(); const anchoredToastManager = Toast.createToastManager(); type ToastId = ReturnType; const threadToastVisibleTimeoutRemainingMs = new Map(); const TOAST_ICONS = { error: CircleAlertIcon, info: InfoIcon, loading: LoaderCircleIcon, success: CircleCheckIcon, warning: TriangleAlertIcon, } as const; type ToastPosition = | "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right"; interface ToastProviderProps extends Toast.Provider.Props { position?: ToastPosition; } function shouldRenderForActiveThread( data: ThreadToastData | undefined, activeThreadId: ThreadId | null, ): boolean { const toastThreadId = data?.threadId; if (!toastThreadId) return true; return toastThreadId === activeThreadId; } function useActiveThreadIdFromRoute(): ThreadId | null { return useParams({ strict: false, select: (params) => typeof params.threadId === "string" ? ThreadId.makeUnsafe(params.threadId) : null, }); } function ThreadToastVisibleAutoDismiss({ toastId, dismissAfterVisibleMs, }: { toastId: ToastId; dismissAfterVisibleMs: number | undefined; }) { useEffect(() => { if (!dismissAfterVisibleMs || dismissAfterVisibleMs <= 0) return; if (typeof window === "undefined" || typeof document === "undefined") return; let remainingMs = threadToastVisibleTimeoutRemainingMs.get(toastId) ?? dismissAfterVisibleMs; let startedAtMs: number | null = null; let timeoutId: number | null = null; let closed = false; const clearTimer = () => { if (timeoutId === null) return; window.clearTimeout(timeoutId); timeoutId = null; }; const closeToast = () => { if (closed) return; closed = true; threadToastVisibleTimeoutRemainingMs.delete(toastId); toastManager.close(toastId); }; const pause = () => { if (startedAtMs === null) return; remainingMs = Math.max(0, remainingMs - (Date.now() - startedAtMs)); startedAtMs = null; clearTimer(); threadToastVisibleTimeoutRemainingMs.set(toastId, remainingMs); }; const start = () => { if (closed || startedAtMs !== null) return; if (remainingMs <= 0) { closeToast(); return; } startedAtMs = Date.now(); clearTimer(); timeoutId = window.setTimeout(() => { remainingMs = 0; startedAtMs = null; closeToast(); }, remainingMs); }; const syncTimer = () => { const shouldRun = document.visibilityState === "visible" && document.hasFocus(); if (shouldRun) { start(); return; } pause(); }; syncTimer(); document.addEventListener("visibilitychange", syncTimer); window.addEventListener("focus", syncTimer); window.addEventListener("blur", syncTimer); return () => { document.removeEventListener("visibilitychange", syncTimer); window.removeEventListener("focus", syncTimer); window.removeEventListener("blur", syncTimer); pause(); clearTimer(); }; }, [dismissAfterVisibleMs, toastId]); return null; } function ToastProvider({ children, position = "top-right", ...props }: ToastProviderProps) { return ( {children} ); } function Toasts({ position = "top-right" }: { position: ToastPosition }) { const { toasts } = Toast.useToastManager(); const activeThreadId = useActiveThreadIdFromRoute(); const isTop = position.startsWith("top"); const visibleToasts = toasts.filter((toast) => shouldRenderForActiveThread(toast.data, activeThreadId), ); const visibleToastLayout = buildVisibleToastLayout(visibleToasts); useEffect(() => { const activeToastIds = new Set(toasts.map((toast) => toast.id)); for (const toastId of threadToastVisibleTimeoutRemainingMs.keys()) { if (!activeToastIds.has(toastId)) { threadToastVisibleTimeoutRemainingMs.delete(toastId); } } }, [toasts]); return ( {visibleToastLayout.items.map(({ toast, visibleIndex, offsetY }) => { const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; const hideCollapsedContent = shouldHideCollapsedToastContent( visibleIndex, visibleToastLayout.items.length, ); return (
{Icon && (
)}
{toast.actionProps && ( {toast.actionProps.children} )}
); })}
); } function AnchoredToastProvider({ children, ...props }: Toast.Provider.Props) { return ( {children} ); } function AnchoredToasts() { const { toasts } = Toast.useToastManager(); const activeThreadId = useActiveThreadIdFromRoute(); return ( {toasts .filter((toast) => shouldRenderForActiveThread(toast.data, activeThreadId)) .map((toast) => { const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; const tooltipStyle = toast.data?.tooltipStyle ?? false; const positionerProps = toast.positionerProps; if (!positionerProps?.anchor) { return null; } return ( {tooltipStyle ? ( ) : (
{Icon && (
)}
{toast.actionProps && ( {toast.actionProps.children} )}
)}
); })}
); } export { ToastProvider, type ToastPosition, toastManager, AnchoredToastProvider, anchoredToastManager, };