loopable / web /src /inbox /InboxPage.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
609fb78 verified
Raw
History Blame Contribute Delete
21.1 kB
// ---------------------------------------------------------------------------
// 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<Inbox>(seed);
const [alerts, setAlerts] = useState<Alert[]>([]);
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 (
<InboxSurface
alerts={alerts}
error={error}
busy={busy}
view={view}
sections={sections}
selected={selected}
selectedId={selectedId}
onSelect={select}
onOpen={open}
onSetRead={setRead}
onMarkAll={markAll}
onRunAlert={runAlertNow}
onDeleteAlert={deleteAlertNow}
canMarkAllNow={canMarkAll(inbox.unread, busy)}
/>
);
}
/**
* ⭐ 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<typeof paneView>;
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 (
<li key={n.id} className={"alerts-row" + (n.read ? "" : " is-unread")}>
<button
type="button"
className="alerts-row-main"
aria-current={n.id === selectedId ? "true" : undefined}
style={n.id === selectedId ? { background: "var(--lp-wash)" } : undefined}
onClick={() => onSelect(n)}
title={subjectOf(n)}
>
{/* THE SUBJECT, on its own line and first — the header this feature never had. */}
<span className="alerts-row-label">
{n.kind === NOTIF_KIND_AUTOMATION ? (
// ⚠ INSIDE the label span, not beside it. `.alerts-row-main` is a flex COLUMN, so a
// sibling here becomes a third ROW stacked above the text
// ([[wrong-parent-not-broken-control]]); inline, it sits on the line it annotates
// and rides the same ellipsis.
<span className="alerts-row-mark">
<Mark size={12} />
</span>
) : null}
{subjectOf(n)}
</span>
<span className="alerts-row-meta">
{kindLabel(n.kind)}
{preview ? ` · ${preview}` : ""}
{" · "}
{/* Readable, never re-derived: `stampText` is string surgery over the server's own
UTC-with-offset stamp (D-18). Parsing it into a browser Date is how a tenant a
day ahead gets told an event happened tomorrow. */}
{stampText(n.at)}
</span>
</button>
<button
type="button"
className="alerts-row-toggle"
aria-label={n.read ? `Mark ${subjectOf(n)} unread` : `Mark ${subjectOf(n)} read`}
title={n.read ? "Mark unread" : "Mark read"}
onClick={() => onSetRead(n, !n.read)}
>
{/* The ACTION, not the state: "Read" beside an unread row reads as a label for the
row itself. */}
{n.read ? "Mark unread" : "Mark read"}
</button>
</li>
);
};
return (
<div className="inbox-page" style={PAGE}>
<header className="alerts-head" style={{ padding: "0 0 14px" }}>
<h2>
<BellIcon size={18} className="alerts-bell" /> Inbox
</h2>
<button
type="button"
className="alerts-markall"
// ⭐⭐ W31-T23 (R6), KEPT: the question is about the ACCOUNT, so it is asked of the
// count the frame already holds. `inbox.unread === 0` was this surface's OWN state
// zero until its fetch landed and zero for ever if it failedso the one verb that
// clears the badge was disabled in exactly the two situations the owner reported.
disabled={!canMarkAllNow}
onClick={onMarkAll}
>
Mark all read
</button>
</header>
{error ? <p className="shell-newdb-err">{error}</p> : null}
<div style={SPLIT}>
<div style={{ ...CARD, flex: "1 1 42%", padding: "4px 16px 16px" }}>
{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. */
<p className="alerts-empty" aria-busy="true">
<span className="lp-spin" role="status" aria-label="Loading the inbox" />
</p>
) : view === "error" ? (
<p className="alerts-empty">
These notifications could not be loaded. The count beside the bell is still this
account&rsquo;s — &ldquo;Mark all read&rdquo; above clears it.
</p>
) : view === "empty" ? (
<p className="alerts-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&rsquo;s menu.
</p>
) : (
<>
{/* ⛔ 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. */}
<h3 className="home-section-title" style={{ margin: "14px 0 6px" }}>
Unread{sections.unread.length ? ` (${sections.unread.length})` : ""}
</h3>
{sections.unread.length ? (
<ul className="alerts-list">{sections.unread.map(row)}</ul>
) : (
<p className="alerts-empty" style={{ margin: "4px 0 10px" }}>
Nothing unread.
</p>
)}
{sections.read.length ? (
<>
<h3 className="home-section-title" style={{ margin: "18px 0 6px" }}>
Earlier
</h3>
<ul className="alerts-list">{sections.read.map(row)}</ul>
</>
) : null}
</>
)}
{alerts.length > 0 ? (
<section className="alerts-watching">
<h3 className="home-section-title">Watching</h3>
{alerts.map((a) => (
<div key={a.id} className="alerts-watch-row">
<span className="alerts-watch-label">{a.label}</span>
<span className="alerts-watch-meta">
{/* 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}` : ""}
</span>
<button
type="button"
className="alerts-watch-run"
disabled={busy}
onClick={() => onRunAlert(a)}
>
Check now
</button>
<button
type="button"
className="alerts-watch-del"
disabled={busy}
aria-label={`Delete the alert ${a.label}`}
onClick={() => onDeleteAlert(a)}
>
Delete
</button>
</div>
))}
</section>
) : null}
</div>
{/* 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. */}
<div className="inbox-detail" style={{ ...CARD, flex: "1 1 58%", padding: "20px 24px" }}>
{selected === null ? (
<p className="alerts-empty">Select a notification to read it.</p>
) : (
<>
<h3
style={{
margin: "0 0 6px",
fontSize: "var(--lp-fs-sm)",
fontWeight: 650,
color: "var(--lp-ink)",
}}
>
{subjectOf(selected)}
</h3>
<p className="alerts-row-meta" style={{ margin: "0 0 16px" }}>
{kindLabel(selected.kind)} · {stampText(selected.at)}
</p>
{previewOf(selected) ? (
<p
style={{
margin: "0 0 18px",
fontSize: "var(--lp-fs-xs)",
lineHeight: 1.55,
color: "var(--lp-ink)",
}}
>
{previewOf(selected)}
</p>
) : null}
{canOpen(selected) ? (
<button
type="button"
className="login-submit"
onClick={() => onOpen(selected)}
>
{/* Item 19 (T29): the verb names the DESTINATION rather than promising
"open" generically — two kinds go two places. */}
{kindLabel(selected.kind) === "Automation"
? "Open the automation"
: "Open the database"}
</button>
) : (
/* ⛔ 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". */
<p className="alerts-empty" style={{ margin: 0 }}>
{UNOPENABLE_NOTE}
</p>
)}
<button
type="button"
className="alerts-row-toggle"
style={{ marginLeft: 10 }}
onClick={() => onSetRead(selected, !selected.read)}
>
{selected.read ? "Mark unread" : "Mark read"}
</button>
</>
)}
</div>
</div>
</div>
);
}