Files
siftlode/frontend/src/components/ui/Toaster.tsx
T
peter 134ec9a68e fix(diagnostics): deep-review round 1 — a send that cannot race, lose or repeat itself
The send, the draft and the screenshots take the session's row lock, so an
autosave or a paste during a send waits for it and then sees it, and two sends
cannot open two issues. A save keeps the delivery's progress; a failed edit
that shows the screenshots is finished by the retry; an answer that is not the
tracker's API (a redirect, a proxy page) is refused, not a crash that loses the
issue's record. Only an attempt that opened an issue counts toward the pace.

Screenshots: deleting one after the send is refused, a user's storage is
capped across sessions, new sessions and zip exports are paced, images are
stored uncompressed in the zip. One liveness rule, one session view, one image
type table.

Logging: a session's elevation is tracked apart from the user's own, so the
browser's own requests (no header) no longer lower the floor a session needs;
Stop ends it at once.

Composer: an image pasted anywhere is taken; what is still unsaved is saved on
close, and "saved" is shown only when it is; the draft is held while it is
sent; a failed screenshot read says so instead of reading as none; focus moves
to what stays; the preview follows the text size. Pill: the step prompt and a
running Stop survive the pill moving between hosts, the prompt keeps the
dialog's Tab trap, the toast region lets clicks through, other tabs hear of a
Start or Stop, the last seconds are sent before the cap, a capture with no
frame is an error.
2026-09-20 00:09:52 +02:00

219 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, 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 <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 (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<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 (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<number> }>({
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.
<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="toast-card glass pointer-events-auto relative overflow-hidden rounded-xl 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
onClick={() => {
toast.action!.onClick();
dismiss(toast.id);
}}
className="mt-1 text-accent text-sm font-semibold hover:underline"
>
{toast.action.label}
</button>
)}
</div>
<button
onClick={() => dismiss(toast.id)}
className="shrink-0 text-muted hover:text-fg"
title={t("notifications.dismiss")}
>
<X className="w-4 h-4" />
</button>
{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">
<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` }}
/>
);
}