| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { Toast } from "@base-ui/react/toast"; |
| 4 | import { useEffect } from "react"; |
| 4 | 5 | import { |
| 5 | 6 | CircleAlertIcon, |
| 6 | 7 | CircleCheckIcon, |
| ... | ... | @@ -16,10 +17,13 @@ import { useStore } from "~/store"; |
| 16 | 17 | type ThreadToastData = { |
| 17 | 18 | threadId?: string | null; |
| 18 | 19 | tooltipStyle?: boolean; |
| 20 | dismissAfterVisibleMs?: number; |
| 19 | 21 | }; |
| 20 | 22 | |
| 21 | 23 | const toastManager = Toast.createToastManager<ThreadToastData>(); |
| 22 | 24 | const anchoredToastManager = Toast.createToastManager<ThreadToastData>(); |
| 25 | type ToastId = ReturnType<typeof toastManager.add>; |
| 26 | const threadToastVisibleTimeoutRemainingMs = new Map<ToastId, number>(); |
| 23 | 27 | |
| 24 | 28 | const TOAST_ICONS = { |
| 25 | 29 | error: CircleAlertIcon, |
| ... | ... | @@ -50,6 +54,84 @@ function shouldRenderForActiveThread( |
| 50 | 54 | return toastThreadId === activeThreadId; |
| 51 | 55 | } |
| 52 | 56 | |
| 57 | function ThreadToastVisibleAutoDismiss({ |
| 58 | toastId, |
| 59 | dismissAfterVisibleMs, |
| 60 | }: { |
| 61 | toastId: ToastId; |
| 62 | dismissAfterVisibleMs: number | undefined; |
| 63 | }) { |
| 64 | useEffect(() => { |
| 65 | if (!dismissAfterVisibleMs || dismissAfterVisibleMs <= 0) return; |
| 66 | if (typeof window === "undefined" || typeof document === "undefined") return; |
| 67 | |
| 68 | let remainingMs = threadToastVisibleTimeoutRemainingMs.get(toastId) ?? dismissAfterVisibleMs; |
| 69 | let startedAtMs: number | null = null; |
| 70 | let timeoutId: number | null = null; |
| 71 | let closed = false; |
| 72 | |
| 73 | const clearTimer = () => { |
| 74 | if (timeoutId === null) return; |
| 75 | window.clearTimeout(timeoutId); |
| 76 | timeoutId = null; |
| 77 | }; |
| 78 | |
| 79 | const closeToast = () => { |
| 80 | if (closed) return; |
| 81 | closed = true; |
| 82 | threadToastVisibleTimeoutRemainingMs.delete(toastId); |
| 83 | toastManager.close(toastId); |
| 84 | }; |
| 85 | |
| 86 | const pause = () => { |
| 87 | if (startedAtMs === null) return; |
| 88 | remainingMs = Math.max(0, remainingMs - (Date.now() - startedAtMs)); |
| 89 | startedAtMs = null; |
| 90 | clearTimer(); |
| 91 | threadToastVisibleTimeoutRemainingMs.set(toastId, remainingMs); |
| 92 | }; |
| 93 | |
| 94 | const start = () => { |
| 95 | if (closed || startedAtMs !== null) return; |
| 96 | if (remainingMs <= 0) { |
| 97 | closeToast(); |
| 98 | return; |
| 99 | } |
| 100 | startedAtMs = Date.now(); |
| 101 | clearTimer(); |
| 102 | timeoutId = window.setTimeout(() => { |
| 103 | remainingMs = 0; |
| 104 | startedAtMs = null; |
| 105 | closeToast(); |
| 106 | }, remainingMs); |
| 107 | }; |
| 108 | |
| 109 | const syncTimer = () => { |
| 110 | const shouldRun = document.visibilityState === "visible" && document.hasFocus(); |
| 111 | if (shouldRun) { |
| 112 | start(); |
| 113 | return; |
| 114 | } |
| 115 | pause(); |
| 116 | }; |
| 117 | |
| 118 | syncTimer(); |
| 119 | document.addEventListener("visibilitychange", syncTimer); |
| 120 | window.addEventListener("focus", syncTimer); |
| 121 | window.addEventListener("blur", syncTimer); |
| 122 | |
| 123 | return () => { |
| 124 | document.removeEventListener("visibilitychange", syncTimer); |
| 125 | window.removeEventListener("focus", syncTimer); |
| 126 | window.removeEventListener("blur", syncTimer); |
| 127 | pause(); |
| 128 | clearTimer(); |
| 129 | }; |
| 130 | }, [dismissAfterVisibleMs, toastId]); |
| 131 | |
| 132 | return null; |
| 133 | } |
| 134 | |
| 53 | 135 | function ToastProvider({ children, position = "top-right", ...props }: ToastProviderProps) { |
| 54 | 136 | return ( |
| 55 | 137 | <Toast.Provider toastManager={toastManager} {...props}> |
| ... | ... | @@ -65,6 +147,15 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 65 | 147 | const activeThreadId = state.activeThreadId; |
| 66 | 148 | const isTop = position.startsWith("top"); |
| 67 | 149 | |
| 150 | useEffect(() => { |
| 151 | const activeToastIds = new Set(toasts.map((toast) => toast.id)); |
| 152 | for (const toastId of threadToastVisibleTimeoutRemainingMs.keys()) { |
| 153 | if (!activeToastIds.has(toastId)) { |
| 154 | threadToastVisibleTimeoutRemainingMs.delete(toastId); |
| 155 | } |
| 156 | } |
| 157 | }, [toasts]); |
| 158 | |
| 68 | 159 | return ( |
| 69 | 160 | <Toast.Portal data-slot="toast-portal"> |
| 70 | 161 | <Toast.Viewport |
| ... | ... | @@ -140,6 +231,10 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 140 | 231 | } |
| 141 | 232 | toast={toast} |
| 142 | 233 | > |
| 234 | <ThreadToastVisibleAutoDismiss |
| 235 | dismissAfterVisibleMs={toast.data?.dismissAfterVisibleMs} |
| 236 | toastId={toast.id} |
| 237 | /> |
| 143 | 238 | <Toast.Content className="pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-3.5 py-3 text-sm transition-opacity duration-250 data-behind:not-data-expanded:pointer-events-none data-behind:opacity-0 data-expanded:opacity-100"> |
| 144 | 239 | <div className="flex gap-2"> |
| 145 | 240 | {Icon && ( |