loopable / web /src /inbox /inboxModel.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
609fb78 verified
Raw
History Blame Contribute Delete
9.83 kB
// ---------------------------------------------------------------------------
// inbox/inboxModel.ts — WAVE 32 · T20 (owner item 16, ruling R7, contract C3):
// the Inbox module's PURE half. React-free and fetch-free, so `verify_alerts.py`
// compiles it and RUNS it under node.
//
// Owner, item 16: *"Alerts becomes an Inbox that looks like email."* An email
// client is three decisions, and every one of them is a function here rather
// than markup, because markup a gate cannot reach is markup no control can
// mutate:
//
// 1. ORDER — unread ABOVE read, newest first inside each. Not a sort key on
// one axis: a reader opens an inbox to find what they have not seen, and
// an unread item that has aged below a read one is functionally lost.
// 2. THE HEADER — `subject` (what this is about) sits above `label` (what
// happened). They were ONE field until C3, which is precisely why the old
// pane could only render a sentence with no sender.
// 3. WHETHER IT OPENS AT ALL — `target` is ABSENT when the server cannot
// resolve a destination, and that absence must reach the eye as a row that
// does not pretend to be clickable.
//
// ⛔ THERE IS NO CLIENT-SIDE `target` DERIVATION HERE, DELIBERATELY. The server
// derives it (`routes_alerts.notification_view`) and this module only reads what
// arrived. A fallback that re-derived the same answer from `topic`/`alertId`
// would be a SECOND normaliser for one question — the exact defect this wave's
// item 6 is about in `routes_connectors` vs `routes_keychain`, and the reason
// every tenant currently sees another tenant's Odoo connection. One question,
// one answer, on the side that owns the vocabulary.
// ---------------------------------------------------------------------------
import {
NOTIF_KIND_AUTOMATION,
NOTIF_KIND_SHARE,
TARGET_MODULE_AUTOMATION,
TARGET_MODULE_DATABASE,
} from "../alerts/alertsModel";
import type { Notification, NotificationTarget } from "../alerts/alertsModel";
const str = (v: unknown): string => (typeof v === "string" ? v : "");
/** One block of the list. `unread` renders first and always, even when empty — an
* inbox that hides its own "Unread" heading at zero makes "I have read everything"
* and "this failed to load" look identical. */
export interface InboxSections {
unread: Notification[];
read: Notification[];
}
/**
* The mail order: unread above read, newest first within each.
*
* ⛔ TWO SECTIONS, NOT ONE SORT, and the difference is visible the moment an inbox has both. A
* single comparator that puts `read` before `at` produces the same sequence but no SEAM — so a
* reader scrolling past the last unread item cannot tell they have crossed it, which is the one
* thing the ordering exists to tell them.
*
* ⚠ STABLE within a timestamp: `_queue` mints several notifications with an IDENTICAL `at`
* (one write, N entrants), so a comparator returning non-zero for equal stamps would reshuffle
* them on every render.
*/
export function inboxSections(items: Notification[]): InboxSections {
const byAt = (a: Notification, b: Notification) =>
a.at < b.at ? 1 : a.at > b.at ? -1 : 0;
return {
unread: items.filter((n) => !n.read).sort(byAt),
read: items.filter((n) => n.read).sort(byAt),
};
}
/**
* The header line.
*
* ⚠ IT FALLS BACK RATHER THAN RENDERING BLANK. A notification queued before C3 existed carries no
* `subject`, and a mail list whose header row is empty reads as a corrupt item — the reader
* cannot tell it apart from one whose subject really is missing. `alertLabel` is what the server
* derives `subject` FROM, so the fallback lands on the same words rather than on a placeholder.
*/
export function subjectOf(n: Notification): string {
return (
str(n.subject).trim() ||
str(n.alertLabel).trim() ||
str(n.label).trim() ||
"Notification"
);
}
/**
* The preview line — what actually happened.
*
* ⚠ EMPTY WHEN IT WOULD MERELY REPEAT THE SUBJECT. On a notification with no `subject` the
* fallback above already used `label`, and printing it twice makes a two-line row that says one
* thing — the shape that reads as a rendering bug rather than as an item.
*/
export function previewOf(n: Notification): string {
const label = str(n.label).trim();
return label && label !== subjectOf(n) ? label : "";
}
/** The kind, in the reader's words. Unknown kinds fall to the alert wording rather than to a raw
* server token — a row is never allowed to print a vocabulary word at somebody. */
export function kindLabel(kind: string | undefined): string {
if (kind === NOTIF_KIND_AUTOMATION) return "Automation";
if (kind === NOTIF_KIND_SHARE) return "Shared with you";
return "Alert";
}
/**
* Where this item opens, or `null`.
*
* ⛔ `null` IS A RENDERED STATE, NOT A SWALLOWED ONE. An alert outlives the view it was made from
* and a database can be un-shared out from under a notification; when that happens the server
* sends no `target` and the Inbox must show a row that is visibly not a link. The failure this
* replaces is the old pane's: it rendered every row as a button and told the reader *"That
* alert's table is no longer available to this account"* only AFTER they clicked.
*/
export function targetOf(n: Notification): NotificationTarget | null {
return n.target ?? null;
}
/** Can this row be opened? The one predicate the row's markup and its click handler share, so
* they cannot disagree about whether a row is a link. */
export function canOpen(n: Notification): boolean {
return targetOf(n) !== null;
}
/**
* Why a row cannot be opened, for the reader.
*
* ⚠ SAID ON THE ROW, NOT ON THE CLICK. The sentence is the same one the pane used to raise as a
* toast; moving it onto the row is the whole difference between "this is unavailable" and "you
* pressed a button and were told off".
*/
export const UNOPENABLE_NOTE = "The thing this is about is no longer available to this account.";
/**
* ⭐ C3's dispatch, as data rather than as a branch in the frame.
*
* The FRAME owns routing (it holds the router and the module surfaces); this module owns the
* question *"which surface, and what does it need?"*. Returning a discriminated answer means A's
* `onOpenTarget` is a switch over two literals it can exhaust, instead of a second copy of the
* `topic`→route table that `routeForTopic` already owns.
*
* ⚠ AN UNKNOWN MODULE ANSWERS `null` AND THE FRAME MUST SAY SO. It must not silently do nothing:
* an unknown module means this client is older than the server that sent it, and a reader who
* clicks and sees no change concludes the Inbox is broken rather than that their tab is stale.
*/
export type TargetRoute =
| { surface: "database"; key: string; viewId?: string }
| { surface: "automation"; autoId: string; tab: string };
/**
* ⭐⭐ WAVE 32 · T29 (owner item 19) — WHY OPENING A TARGET NEEDS MORE THAN ONE EMIT.
*
* `CustomerGrid`'s `VIEW_OPEN_EVENT` listener drops anything it cannot yet resolve:
*
* if (!detail || detail.topic !== scope) return;
* if (!views.some((v) => v.id === detail.viewId)) return;
*
* Both guards are RIGHT — an alert outlives the view it watches, and one grid must not react to
* another's event. But together they mean a single emit fired the instant the hash changes is
* **silently discarded**: the destination grid has not fetched its views yet, and there is no ack
* channel to wait on. The click appears to work, the table opens, and the view is simply not
* selected — this repo's most-repeated failure shape, on the feature whose whole purpose is
* "clicking an Inbox item opens the thing it is about".
*
* ⭐ FOUND BY LANE E, NOT BY ME, and re-verified here against `CustomerGrid.tsx` before being
* built on: `VIEW_OPEN_EVENT` had a listener and NO emitter anywhere in the tree until this wave,
* so wave 20's click-through had never actually been exercised.
*
* ⚠ RE-EMITTING IS FREE. `selectView` early-returns once the view is already active, and the
* listener drops a duplicate that arrives after selection, so a late tick costs nothing. The
* ladder is coarse on purpose — a tight interval would fire a dozen times inside one render.
*/
export const OPEN_RETRY_MS = [0, 250, 700, 1500, 2600] as const;
/**
* Fire `emit` on the {@link OPEN_RETRY_MS} ladder, stopping early when it reports success.
*
* Returns a CANCEL function: a reader who clicks a second notification while the first is still
* retrying would otherwise have two ladders racing, and the older one would yank them back.
*
* ⚠ `emit` returning `true` means "this landed" and ends the ladder. It is allowed to return
* nothing — `VIEW_OPEN_EVENT` has no ack, so the honest answer there is `undefined` and the
* ladder simply runs out. The signature admits both rather than pretending a confirmation exists.
*/
export function retryEmit(
emit: () => boolean | void,
schedule: (fn: () => void, ms: number) => number = setTimeout as never
): () => void {
const timers: number[] = [];
let done = false;
for (const ms of OPEN_RETRY_MS) {
timers.push(
schedule(() => {
if (done) return;
if (emit() === true) done = true;
}, ms)
);
}
return () => {
done = true;
for (const t of timers) clearTimeout(t as never);
};
}
export function routeForTarget(t: NotificationTarget | null): TargetRoute | null {
if (!t) return null;
if (t.module === TARGET_MODULE_AUTOMATION)
return { surface: "automation", autoId: t.id, tab: str(t.tab).trim() || "runs" };
if (t.module === TARGET_MODULE_DATABASE) {
const viewId = str(t.tab).trim();
return { surface: "database", key: t.id, ...(viewId ? { viewId } : {}) };
}
return null;
}