"use client"; import { Toast } from "@base-ui/react/toast"; import { useEffect, useMemo, type CSSProperties } from "react"; import { useParams } from "@tanstack/react-router"; import { type ScopedThreadRef, type ThreadId } from "@t3tools/contracts"; import { CheckIcon, CircleAlertIcon, CircleCheckIcon, CopyIcon, InfoIcon, LoaderCircleIcon, TriangleAlertIcon, } from "lucide-react"; import { cn } from "~/lib/utils"; import { buttonVariants } from "~/components/ui/button"; import { useComposerDraftStore } from "~/composerDraftStore"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { resolveThreadRouteTarget } from "~/threadRoutes"; import { buildVisibleToastLayout, shouldHideCollapsedToastContent, shouldRenderThreadScopedToast, } from "./toast.logic"; export type ThreadToastData = { threadRef?: ScopedThreadRef | null; threadId?: ThreadId | null; tooltipStyle?: boolean; dismissAfterVisibleMs?: number; hideCopyButton?: boolean; }; 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; function CopyErrorButton({ text }: { text: string }) { const { copyToClipboard, isCopied } = useCopyToClipboard(); return ( ); } type ToastPosition = | "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right"; interface ToastProviderProps extends Toast.Provider.Props { position?: ToastPosition; } function useActiveThreadRefFromRoute(): ScopedThreadRef | null { const routeTarget = useParams({ strict: false, select: (params) => resolveThreadRouteTarget(params), }); const activeDraftSession = useComposerDraftStore((store) => routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, ); return useMemo(() => { if (routeTarget?.kind === "server") { return routeTarget.threadRef; } if (routeTarget?.kind === "draft" && activeDraftSession) { return { environmentId: activeDraftSession.environmentId, threadId: activeDraftSession.threadId, }; } return null; }, [activeDraftSession, routeTarget]); } 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 activeThreadRef = useActiveThreadRefFromRoute(); const isTop = position.startsWith("top"); const visibleToasts = toasts.filter((toast) => shouldRenderThreadScopedToast(toast.data, activeThreadRef), ); 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.type === "error" && typeof toast.description === "string" && !toast.data?.hideCopyButton && }
{toast.actionProps && ( {toast.actionProps.children} )}
); })}
); } function AnchoredToastProvider({ children, ...props }: Toast.Provider.Props) { return ( {children} ); } function AnchoredToasts() { const { toasts } = Toast.useToastManager(); const activeThreadRef = useActiveThreadRefFromRoute(); return ( {toasts .filter((toast) => shouldRenderThreadScopedToast(toast.data, activeThreadRef)) .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.type === "error" && typeof toast.description === "string" && !toast.data?.hideCopyButton && ( )}
{toast.actionProps && ( {toast.actionProps.children} )}
)}
); })}
); } export { ToastProvider, type ToastPosition, toastManager, AnchoredToastProvider, anchoredToastManager, };