Files
siftlode/frontend/src/components/ui/Toaster.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

228 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <body> 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 `<Toaster overlay order={…} />` 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<readonly Host[]>([]);
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<number> = new Set<number>();
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<number> }>({
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.
<div
// lib/listNav: focus on a toast's Undo — or on the docked control — is not the reader leaving
// the list the toast is about.
data-toast-region=""
// Click-through in every host: the region is as wide as its (always present) list and as tall
// as the docked pill, and without this the empty part beside and above the pill swallowed the
// page's clicks. The cards and the dock opt back in.
className={`absolute left-4 z-overlay flex flex-col gap-2 max-w-[calc(100%-2rem)] pointer-events-none ${
overlay ? "bottom-24" : "bottom-4"
}`}
// Inside an overlay, a click on a toast is not a click on the overlay's backdrop.
onClick={overlay ? (e) => e.stopPropagation() : undefined}
>
<div
aria-live={isTop && armed ? "polite" : "off"}
className="flex flex-col gap-2 w-80 max-w-full"
>
{toasts.map((toast) => {
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
const urgent = toast.level === "error" || toast.level === "fatal";
return (
<div
key={toast.id}
// A toast this host did not inherit must be announced even in the unarmed window, where
// the region is still `off`: urgent ones assertively, the rest with their own polite
// `role="status"` (single-element, so its implied atomic re-read is just this toast).
// Once armed, the region itself does the announcing and the role comes back off.
role={
inherited.has(toast.id)
? undefined
: urgent
? "alert"
: armed
? undefined
: "status"
}
className={`${SURFACE.popover} toast-card pointer-events-auto relative overflow-hidden px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]`}
>
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1">
{toast.title && <div className="text-sm font-semibold">{toast.title}</div>}
<div className="text-sm wrap-break-word">
{toast.message}
{toast.repeat > 1 && (
<span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums">
×{toast.repeat}
</span>
)}
</div>
{toast.action && (
<Button
variant="link"
onClick={() => {
toast.action!.onClick();
dismiss(toast.id);
}}
className="mt-1 text-sm"
>
{toast.action.label}
</Button>
)}
</div>
<IconButton
onClick={() => dismiss(toast.id)}
label={t("notifications.dismiss")}
className="-m-1.5"
>
<X />
</IconButton>
{toast.duration && (
// Keyed by `ts`: a coalesced repeat re-arms the dismiss timer, so its bar restarts too.
<CountdownBar key={toast.ts} since={toast.ts} duration={toast.duration} bar={bar} />
)}
</div>
);
})}
</div>
{isTop && Docked && (
<div className="pointer-events-auto w-max max-w-full">
{/* 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 */}
<Docked />
</div>
)}
</div>
);
}
/** 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 (
<div
className={`absolute left-0 bottom-0 h-0.5 w-full origin-left ${bar}`}
style={{ animation: `toastbar ${duration}ms linear -${elapsed}ms forwards` }}
/>
);
}