|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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";
|
|
|
|
|
|
|
|
|
|
|
|
|
| import { Mark } from "../shell/Brand";
|
|
|
| |
| |
|
|
| 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;
|
|
|
|
|
|
|
| publish({ ...applyRead(inbox, null, true), unread: 0 });
|
| void markRead(null, true).then((r) => {
|
| if (!r.ok) {
|
| publish(before);
|
| onToast(r.message);
|
| }
|
| });
|
| }, [inbox, publish, onToast]);
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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)}
|
| />
|
| );
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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 failed — so 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’s — “Mark all read” 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’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>
|
|
|
| { |
| }
|
| <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>
|
| );
|
| }
|
|
|