import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { X } from "lucide-react"; import { dismiss, getActiveToasts, subscribe } from "../../lib/notifications"; import { createStore } from "../../lib/store"; import { useToastDock } from "../../lib/toastDock"; import { useOverlayOrder } from "./overlayOrder"; import { LEVEL_STYLE } from "./toastLevels"; // Overlay hosts. The app's own Toaster sits in the content column, and that column is a stacking // context (its `@container` implies layout containment) — so anything portalled to at the // same z-overlay painted over every toast: a failure raised while watching was drawn under an 80% // black, blurred scrim, and a click on it closed the player. So every full-screen overlay mounts its // OWN `` inside itself — ui/Modal does it for every dialog, the players // for themselves — and exactly one host shows the toasts: the one of the overlay OPENED last, else // the app's. Same component, same look — and inside the overlay's aria-modal dialog, where a screen // reader can still reach it. // // "Opened last", not "host mounted last": a host can remount while its overlay stays open — // PlayerModal's moves into the fullscreen stage and back — and ranked by mount time it came back on // top of a dialog opened over the player (the download dialog after leaving true fullscreen), so the // toasts were drawn under that dialog's scrim again (deep-review round 4). So the OVERLAY takes its // rank once, when it opens (`useOverlayOrder`, in overlayOrder.ts), and hands it to every host it renders. type Host = { id: string; order: number }; const overlayHosts = createStore([]); const topHost = () => { let top: Host | null = null; for (const h of overlayHosts.get()) if (!top || h.order >= top.order) top = h; return top?.id ?? null; }; // What the host that is currently top last rendered, for the next host to inherit. See `inherited`. let lastTopIds: number[] = []; const EMPTY_IDS: ReadonlySet = new Set(); export default function Toaster({ overlay = false, order, }: { overlay?: boolean; /** The owning overlay's `useOverlayOrder()`. Without one, the host ranks by its own mount. */ order?: number; }) { const { t } = useTranslation(); const id = useId(); const ownOrder = useOverlayOrder(); const rank = order ?? ownOrder; useEffect(() => { if (!overlay) return; overlayHosts.set((hosts) => [...hosts, { id, order: rank }]); return () => overlayHosts.set((hosts) => hosts.filter((h) => h.id !== id)); }, [overlay, id, rank]); const top = useSyncExternalStore(overlayHosts.subscribe, topHost, topHost); const active = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts); const Docked = useToastDock(); const isTop = top === (overlay ? id : null); const toasts = isTop ? active : []; // A host that takes over (an overlay opening, or the app's own once it closes) receives the toasts // that were already up as NEW nodes — and a screen reader announced every one of them again, an // error assertively. So the ones it inherits are shown silently: no `role="alert"`, and the region // stays `aria-live="off"` until they are in, turning polite (for the toasts that come after) in a // LATER task. Not in an effect: React flushes those in the same task as the render that inserted the // toasts, and a screen reader reads the region's state after the task — by which time it was polite // again, i.e. the arming did nothing. // // "Inherited" is what the PREVIOUS top host last had on screen — not whatever is in the store at // the takeover render. Reading the store live also silenced a toast RAISED in the same commit as // the takeover: a dialog that notifies and closes in one handler (UploadDialog) put its success // toast up in the very batch that handed the app host the top spot, so it was classified as // inherited and announced by neither path (deep-review round 5). Written during render on purpose // — the handover must be readable by the taking-over host in the SAME commit, and an effect // cleanup runs one phase too late; only the host that IS top writes, so the value is the losing // host's last screen either way. // // Snapshotted through a ref keyed on `isTop`, not a `useMemo`: React re-invokes a component body // — and every `useMemo` factory in it — on a StrictMode double render and on a restarted one, and // the LAST invocation is what commits. With a memo the second pass re-read `lastTopIds` after the // first pass had already overwritten it on the line below, so the takeover host classified its // OWN new toast as inherited and the dev build behaved exactly like the code this replaced // (measured in jsdom: role "status" without StrictMode, null with it — round 6). The ref only // recomputes when `isTop` actually flips, so a repeated invocation of one render is a no-op. const handover = useRef<{ top: boolean; ids: ReadonlySet }>({ top: false, ids: EMPTY_IDS, }); if (handover.current.top !== isTop) handover.current = { top: isTop, ids: new Set(isTop ? lastTopIds : []) }; const inherited = handover.current.ids; // AFTER the snapshot above has read the outgoing host's screen — this host's own first render as // top would otherwise overwrite the handover with the toasts it is about to classify. Writing the // same value twice is harmless; that is the point of the ref. if (isTop) lastTopIds = toasts.map((n) => n.id); const [armed, setArmed] = useState(false); useEffect(() => { if (!isTop) { setArmed(false); return; } const timer = window.setTimeout(() => setArmed(true), 0); return () => window.clearTimeout(timer); }, [isTop]); return ( // Stable, always-mounted live region — a polite region must PRE-EXIST in the DOM for a screen // reader to announce content later inserted into it (a region created together with its first // message is unreliably announced). `aria-live` (not role="status") on purpose: role="status" // implies aria-atomic="true", which would re-read the WHOLE stack on every toast; aria-live // defaults atomic=false, so only the newly-added toast is spoken. Error/fatal toasts additionally // carry role="alert", which announces assertively on insertion regardless. // // Inside an overlay it sits ABOVE the bottom band: that is where the players keep their control // bars (the Plex transport row, PlayerModal's docked bar), and the toast stack — on top of them at // z-overlay, and catching the click — took play/pause, ±10, stop, prev/next and mute for as long // as a toast was up (the connection notice: until the server answered). The same rung UpNextCard // uses for the same reason. Clicks between the cards go through to what is underneath. // Its width is capped by its HOST (100%), not the viewport: it sits in the content column, right // of the sidebar, and a 100vw cap pushed it past the right edge of a phone (prod UAT 2026-09-19). // // Under the stack sits whatever is docked (lib/toastDock — the debug recorder's pill), in the top // host only, and OUTSIDE the live region: a ticking timer inside it would be read out every second.
e.stopPropagation() : undefined} >
{toasts.map((toast) => { const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level]; const urgent = toast.level === "error" || toast.level === "fatal"; return (
{toast.title &&
{toast.title}
}
{toast.message} {toast.repeat > 1 && ( ×{toast.repeat} )}
{toast.action && ( )}
{toast.duration && ( // Keyed by `ts`: a coalesced repeat re-arms the dismiss timer, so its bar restarts too. )}
); })}
{isTop && Docked && (
)}
); } /** The auto-dismiss countdown, started as far in as the toast already is. A toast handed to another * host is a new node, and its bar used to start again from full while the dismiss timer — armed * once, at notify time — did not: the toast vanished with the bar still two-thirds full. The offset * is taken once, at mount; recomputed on a later render it would jump the running animation ahead. */ function CountdownBar({ since, duration, bar }: { since: number; duration: number; bar: string }) { const [elapsed] = useState(() => Math.min(duration, Math.max(0, Date.now() - since))); return (
); }