import { useEffect, useId, useLayoutEffect, 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"; import { Button } from "./Button"; import { IconButton } from "./IconButton"; import { SURFACE } from "./surfaces"; // 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. 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. The top host records its screen // after each commit (the layout effect below); a host taking over reads it in a LATER render, after // that commit, so it always sees the outgoing host's last committed screen. (It was written during // render, which made the React Compiler skip the Toaster and let a discarded render // overwrite it.) // // Snapshotted into state when `isTop` flips, adjusted DURING render — not a `useMemo`, which React // may recompute (and the compiler is free to): a recompute while top would re-read this host's OWN // screen and silence its next toast. Under StrictMode the double invocation reads the same, // unchanged global, so both passes agree (an earlier failure was a render-phase WRITE racing it). const [handover, setHandover] = useState<{ top: boolean; ids: ReadonlySet }>({ top: false, ids: EMPTY_IDS, }); let inherited = handover.ids; if (handover.top !== isTop) { const next = { top: isTop, ids: new Set(isTop ? lastTopIds : []) }; setHandover(next); inherited = next.ids; } useLayoutEffect(() => { if (isTop) lastTopIds = toasts.map((n) => n.id); }); const [armed, setArmed] = useState(false); useEffect(() => { if (!isTop) { // eslint-disable-next-line react-hooks/set-state-in-effect -- arming is a TIMER, not derived state: the live region must be mounted and empty for a tick before content lands in it or a screen reader never announces the first toast. Deriving `isTop && armed` would let a host that regains top skip that tick. 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 && ( )}
dismiss(toast.id)} label={t("notifications.dismiss")} className="-m-1.5" > {toast.duration && ( // Keyed by `ts`: a coalesced repeat re-arms the dismiss timer, so its bar restarts too. )}
); })}
{isTop && Docked && (
{/* lib/toastDock is a one-slot registry: this identity changes only when a surface docks or undocks, and remounting is then the correct behaviour, not the hazard the rule warns about. */} {/* eslint-disable-next-line react-hooks/static-components -- a one-slot dock registry: the identity changes only on dock/undock, where a remount is right */}
)}
); } /** 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 (
); }