| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | | 2 | |
| 3 | import { Toast } from "@base-ui/react/toast"; | 3 | import { Toast } from "@base-ui/react/toast"; |
| | 4 | import { useEffect } from "react"; |
| 4 | import { | 5 | import { |
| 5 | CircleAlertIcon, | 6 | CircleAlertIcon, |
| 6 | CircleCheckIcon, | 7 | CircleCheckIcon, |
| ... | @@ -16,10 +17,13 @@ import { useStore } from "~/store"; | ... | @@ -16,10 +17,13 @@ import { useStore } from "~/store"; |
| 16 | type ThreadToastData = { | 17 | type ThreadToastData = { |
| 17 | threadId?: string | null; | 18 | threadId?: string | null; |
| 18 | tooltipStyle?: boolean; | 19 | tooltipStyle?: boolean; |
| | 20 | dismissAfterVisibleMs?: number; |
| 19 | }; | 21 | }; |
| 20 | | 22 | |
| 21 | const toastManager = Toast.createToastManager<ThreadToastData>(); | 23 | const toastManager = Toast.createToastManager<ThreadToastData>(); |
| 22 | const anchoredToastManager = Toast.createToastManager<ThreadToastData>(); | 24 | const anchoredToastManager = Toast.createToastManager<ThreadToastData>(); |
| | 25 | type ToastId = ReturnType<typeof toastManager.add>; |
| | 26 | const threadToastVisibleTimeoutRemainingMs = new Map<ToastId, number>(); |
| 23 | | 27 | |
| 24 | const TOAST_ICONS = { | 28 | const TOAST_ICONS = { |
| 25 | error: CircleAlertIcon, | 29 | error: CircleAlertIcon, |
| ... | @@ -50,6 +54,84 @@ function shouldRenderForActiveThread( | ... | @@ -50,6 +54,84 @@ function shouldRenderForActiveThread( |
| 50 | return toastThreadId === activeThreadId; | 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 | function ToastProvider({ children, position = "top-right", ...props }: ToastProviderProps) { | 135 | function ToastProvider({ children, position = "top-right", ...props }: ToastProviderProps) { |
| 54 | return ( | 136 | return ( |
| 55 | <Toast.Provider toastManager={toastManager} {...props}> | 137 | <Toast.Provider toastManager={toastManager} {...props}> |
| ... | @@ -65,6 +147,15 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { | ... | @@ -65,6 +147,15 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 65 | const activeThreadId = state.activeThreadId; | 147 | const activeThreadId = state.activeThreadId; |
| 66 | const isTop = position.startsWith("top"); | 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 | return ( | 159 | return ( |
| 69 | <Toast.Portal data-slot="toast-portal"> | 160 | <Toast.Portal data-slot="toast-portal"> |
| 70 | <Toast.Viewport | 161 | <Toast.Viewport |
| ... | @@ -140,6 +231,10 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { | ... | @@ -140,6 +231,10 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 140 | } | 231 | } |
| 141 | toast={toast} | 232 | toast={toast} |
| 142 | > | 233 | > |
| | 234 | <ThreadToastVisibleAutoDismiss |
| | 235 | dismissAfterVisibleMs={toast.data?.dismissAfterVisibleMs} |
| | 236 | toastId={toast.id} |
| | 237 | /> |
| 143 | <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"> | 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 | <div className="flex gap-2"> | 239 | <div className="flex gap-2"> |
| 145 | {Icon && ( | 240 | {Icon && ( |