// --------------------------------------------------------------------------- // inbox/InboxPage.tsx — WAVE 32 · T20 (owner item 16, ruling R7, contract C3). // // Owner, item 16: *"Alerts becomes an Inbox that looks like email."* R7: it is a // FULL MODULE and the pop-up pane is deleted — the bell navigates here, there is // no dropdown. // // So this is a LIST + a READING PANE, which is the only structural thing an email // client actually is. The pane it replaces was a 520px drawer with one column, and // that shape is why the feature never read like mail: a drawer has no room for a // subject line above a body, so subject and body were one field, and a // notification could only ever be a sentence with no sender. // // ⛔ NOT A REGISTRY MODULE — a CHROME route, like Home and Connectors. Every // provisioned tenant carries a restricted `modules` list (`['analyst','automation']` // on all three), and a registry key outside it is silently OMITTED from the rail. // Registering Inbox would make it invisible in every tenant while every gate stayed // green. `alerts` was already chrome; Inbox inherits the right shape. The MOUNT is // A's (`shell/Shell.tsx`, W32-T04); this file only exports the surface. // // ⚠ STYLING REUSES THE `.alerts-*` RULES ON PURPOSE. They are the same feature's // classes, they already exist, and `src/index.css` is in no lane's fence this wave // (D-186's shape, second occurrence). Page-frame geometry that has no class yet is // inline rather than invented: a class name with no rule behind it looks identical // to a rule that failed to load. // --------------------------------------------------------------------------- import { useCallback, useEffect, useMemo, useState } from "react"; import { deleteAlert, fetchAlerts, fetchInbox, markRead, runAlert, } from "../alerts/alertsApi"; import { NOTIF_KIND_AUTOMATION, applyRead, canMarkAll, paneView, stampText, } from "../alerts/alertsModel"; import type { Alert, Inbox, Notification, NotificationTarget } from "../alerts/alertsModel"; import type { InboxSections } from "./inboxModel"; import { UNOPENABLE_NOTE, canOpen, inboxSections, kindLabel, previewOf, subjectOf, targetOf, } from "./inboxModel"; import { BellIcon } from "../ui/icons"; // ⭐ WAVE 27 C5, CARRIED FORWARD — the ONE generated loop mark, consumed rather than redrawn. // ⛔ It is here because W32-T21 DELETES `AlertsPane`, and a rename ticket must not quietly drop a // shipped visual: an automation row wore this mark, and losing it in the move would be a silent // regression behind a green gate. ⛔ NOT A STATUS DOT (W26 R14 deleted the whole status // vocabulary from the automation module) and never a hand-copied path — `shell/Brand.tsx`'s own // header records a redrawn copy silently painting LAST WAVE'S BRAND while a comment claimed parity. import { Mark } from "../shell/Brand"; /** The page frame. `.shell-home`'s geometry, without borrowing Home's class name for a * surface that is not Home — the rule is six declarations and copying them is cheaper than * a shared class two modules would then have to agree about. */ const PAGE: React.CSSProperties = { height: "100%", overflow: "hidden", padding: "34px 44px 28px", boxSizing: "border-box", background: "var(--lp-wash)", display: "flex", flexDirection: "column", }; const SPLIT: React.CSSProperties = { flex: "1 1 auto", minHeight: 0, display: "flex", gap: 20, alignItems: "stretch", }; const CARD: React.CSSProperties = { background: "var(--lp-surface)", border: "1px solid var(--lp-line)", borderRadius: "var(--lp-r-lg)", overflow: "auto", minHeight: 0, }; export default function InboxPage({ onOpenTarget, onInbox, onToast, seed, }: { /** * ⛔ REQUIRED, NOT OPTIONAL, and this is the lesson written into a type rather than a comment. * An optional callback the frame forgot to pass degrades to "clicking an Inbox item does * nothing" — indistinguishable from "the feature was never built", and red in no gate. A * required prop fails `tsc` the moment it is unmounted. `AlertsPane` carried the same rule on * `onOpenAutomation` and `seed`; C3 carries it forward as the wave's cross-fence contract. * * The FRAME owns routing — it holds the router and knows which module surfaces exist. This * module owns the QUESTION (`inboxModel.routeForTarget` turns a wire target into a surface + * what it needs) so the frame's dispatcher is a switch over two literals rather than a second * copy of the topic→route table. */ onOpenTarget: (t: NotificationTarget) => void; /** Hand the freshly-read inbox back so the rail badge and this module agree. */ onInbox: (inbox: Inbox) => void; onToast: (message: string) => void; /** * ⛔ REQUIRED, for the reason `onOpenTarget` is (W31-T23's shipped bug, kept as a type). The * frame has ALREADY fetched this — it is what paints the rail badge the user just clicked — so * the module opens knowing the account's count, and "Mark all read" is live from the first * frame and stays live if this module's own read fails. */ seed: Inbox; }) { const [inbox, setInbox] = useState(seed); const [alerts, setAlerts] = useState([]); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); const [selectedId, setSelectedId] = useState(""); /** ⛔ THE STATE THE PANE DID NOT HAVE (W31-T23). Without it "no rows yet" and "no rows, ever" * are the same value, so an in-flight read — 3,280 ms live on tenant #0 — printed a confident * "Nothing new." over an inbox nobody had looked at yet. */ const [phase, setPhase] = useState<"pending" | "ready" | "error">("pending"); const publish = useCallback( (next: Inbox) => { setInbox(next); onInbox(next); }, [onInbox] ); const load = useCallback(async () => { const [inboxRes, alertRes] = await Promise.all([fetchInbox(), fetchAlerts()]); if (inboxRes.ok) { publish(inboxRes.value); setError(""); setPhase("ready"); } else { // ⚠ THE SEEDED INBOX SURVIVES A FAILED READ. Blanking it would take the count — and with it // the Mark-all control — away from a reader who can see the badge. The failure is REPORTED // and nothing the frame already knew is discarded. setError(inboxRes.message); setPhase("error"); } if (alertRes.ok) setAlerts(alertRes.value); }, [publish]); useEffect(() => { void load(); }, [load]); const sections = useMemo(() => inboxSections(inbox.items), [inbox.items]); const rowCount = sections.unread.length + sections.read.length; const view = paneView(phase, rowCount, seed.unread); const selected = useMemo( () => inbox.items.find((n) => n.id === selectedId) ?? null, [inbox.items, selectedId] ); const setRead = useCallback( (n: Notification, read: boolean) => { if (n.read === read) return; // Optimistic, with the server's own convention (`ids`, `read`) sent explicitly — and // reverted OUT LOUD if refused, because a badge that silently disagrees with the list is // how a reader learns to stop trusting it. const before = inbox; publish(applyRead(inbox, [n.id], read)); void markRead([n.id], read).then((r) => { if (!r.ok) { publish(before); onToast(r.message); } }); }, [inbox, publish, onToast] ); const markAll = useCallback(() => { const before = inbox; // `applyRead(…, null, true)` zeroes the count from the ITEMS, which is right here because the // request below clears the whole ACCOUNT (`ids: null`) — 0 is what the server is about to // make true. The revert on failure is what keeps it honest. publish({ ...applyRead(inbox, null, true), unread: 0 }); void markRead(null, true).then((r) => { if (!r.ok) { publish(before); onToast(r.message); } }); }, [inbox, publish, onToast]); /** * ⭐ SELECTING IS READING. Opening a mail item marks it read — that is what the metaphor * promises, and a reader who has to press a second button to clear a badge they have plainly * seen concludes the badge is decorative. * * ⛔ IT DOES NOT NAVIGATE. Item 19 (T29) is the separate act: the reading pane offers "Open …", * which is where `onOpenTarget` fires. The old pane conflated them, so there was no way to READ * a notification without leaving the surface you were reading it on. */ const select = useCallback( (n: Notification) => { setSelectedId(n.id); setRead(n, true); }, [setRead] ); const open = useCallback( (n: Notification) => { const t = targetOf(n); if (!t) return onToast(UNOPENABLE_NOTE); setRead(n, true); onOpenTarget(t); }, [onOpenTarget, onToast, setRead] ); const runAlertNow = useCallback( (a: Alert) => { setBusy(true); void runAlert(a.id).then((r) => { setBusy(false); if (!r.ok) return onToast(r.message); const v = r.value as { new?: unknown[]; skipped?: string }; onToast( v?.skipped ? `Skipped: ${v.skipped}` : `${(v?.new ?? []).length} new since the last check.` ); void load(); }); }, [load, onToast] ); const deleteAlertNow = useCallback( (a: Alert) => { setBusy(true); void deleteAlert(a.id).then((r) => { setBusy(false); if (!r.ok) return onToast(r.message); setAlerts((cur) => cur.filter((x) => x.id !== a.id)); void load(); }); }, [load, onToast] ); return ( ); } /** * ⭐ THE SURFACE, WITH NO FETCH IN IT — every value arrives as a prop. * * ⛔ SPLIT FOR ONE REASON, AND IT IS THE `done-when`: *"observed by a person"*. `InboxPage` reads * on mount, and `renderToStaticMarkup` never runs an effect — so a shot of the page can only ever * photograph its spinner. `_inbox_shot.tsx` renders THIS with rows that came through the real * server derivation (`routes_alerts.inbox_view`), so what a reviewer looks at is the shipped * markup over the shipped payload rather than a picture of a fixture. It is the same division * `connectors/_conn_shot.tsx` already relies on. */ export function InboxSurface({ alerts, error, busy, view, sections, selected, selectedId, onSelect, onOpen, onSetRead, onMarkAll, onRunAlert, onDeleteAlert, canMarkAllNow, }: { alerts: Alert[]; error: string; busy: boolean; view: ReturnType; sections: InboxSections; selected: Notification | null; selectedId: string; onSelect: (n: Notification) => void; onOpen: (n: Notification) => void; onSetRead: (n: Notification, read: boolean) => void; onMarkAll: () => void; onRunAlert: (a: Alert) => void; onDeleteAlert: (a: Alert) => void; canMarkAllNow: boolean; }) { const row = (n: Notification) => { const preview = previewOf(n); return (
  • ); }; return (

    Inbox

    {error ?

    {error}

    : null}
    {view === "pending" ? ( /* R6: the mark, never the word. `verify_icons` asserts this product ships no load TEXT — and the sentence that used to occupy this space was worse than a word, it was an ANSWER ("Nothing new.") to a question nobody had asked yet. */

    ) : view === "error" ? (

    These notifications could not be loaded. The count beside the bell is still this account’s — “Mark all read” above clears it.

    ) : view === "empty" ? (

    Nothing new. An alert watches ONE view and tells you when a record it had never matched arrives in it — make one from a view’s menu.

    ) : ( <> {/* ⛔ THE UNREAD HEADING RENDERS AT ZERO TOO. Hiding it would make "I have read everything" and "the unread half failed to render" the same picture. */}

    Unread{sections.unread.length ? ` (${sections.unread.length})` : ""}

    {sections.unread.length ? (
      {sections.unread.map(row)}
    ) : (

    Nothing unread.

    )} {sections.read.length ? ( <>

    Earlier

      {sections.read.map(row)}
    ) : null} )} {alerts.length > 0 ? (

    Watching

    {alerts.map((a) => (
    {a.label} {/* The remembered set's SIZE, which is what "no news" means here: an alert with 40 matches and nothing new is working. */} {a.matched.toLocaleString()} matched {a.lastError ? ` · ${a.lastError}` : ""}
    ))}
    ) : null}
    {/* THE READING PANE. Always present, so the layout does not jump when a row is picked — and so the surface reads as mail before anything is selected. */}
    {selected === null ? (

    Select a notification to read it.

    ) : ( <>

    {subjectOf(selected)}

    {kindLabel(selected.kind)} · {stampText(selected.at)}

    {previewOf(selected) ? (

    {previewOf(selected)}

    ) : null} {canOpen(selected) ? ( ) : ( /* ⛔ SAID ON THE ITEM, NOT ON THE CLICK. The pane raised this as a toast AFTER the reader pressed a button that looked live — "you pressed this and were told off" rather than "this is unavailable". */

    {UNOPENABLE_NOTE}

    )} )}
    ); }