File size: 12,229 Bytes
609fb78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f546440
 
609fb78
 
f546440
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609fb78
f546440
 
609fb78
 
 
f546440
 
 
 
609fb78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f546440
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609fb78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
// ---------------------------------------------------------------------------
// 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[],
                              held: ReadonlySet<string> = EMPTY_HELD): InboxSections {
  const byAt = (a: Notification, b: Notification) =>
    a.at < b.at ? 1 : a.at > b.at ? -1 : 0;
  // ⭐⭐ W33-T28 β€” `held` IS WHY THE ROW YOU CLICKED STAYS WHERE YOU CLICKED IT.
  //
  // β›” THE DEFECT, found by a `verifier` and worth stating in full because it is the first thing
  // a reader meets: selecting a row marks it read IN THE SAME CLICK (that is deliberate β€” see
  // `InboxPage.select`), which moved it out of `unread`, into `read`, under a different heading,
  // and decremented "Unread (3)". **The list re-sectioned itself while the cursor was still on
  // the row.** No mail client does that; every one of them leaves a message where it is until
  // the next load. The bug is not the marking, it is that the SECTIONING is a live function of
  // the same flag.
  //
  // ⚠ SO THE FIX IS A HOLD, NOT A DELAY. `held` is the ids read during THIS visit; they render
  // in place, styled as read, and settle into "Earlier" on the next load. The unread COUNT is
  // untouched (it is the server's number and the badge's β€” see `parseInbox`'s note), so the badge
  // still clears immediately, which is the half the reader wanted.
  const isUnread = (n: Notification) => !n.read || held.has(n.id);
  return {
    unread: items.filter(isUnread).sort(byAt),
    read: items.filter((n) => !isUnread(n)).sort(byAt),
  };
}

/** A stable empty identity, so the default argument does not mint a Set per call and defeat
 *  `useMemo` at the one call site that has one. */
const EMPTY_HELD: ReadonlySet<string> = new Set<string>();

/**
 * 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";
}

/**
 * ⭐⭐ W33-T28 β€” WHO IT IS FROM, which this list did not have.
 *
 * β›” `kindLabel` WAS STANDING IN THE SENDER'S PLACE and that is the defect, not the styling: a
 * `verifier` reading the finished wave-32 Inbox found the row's first meta token was always
 * "Alert" / "Automation" / "Shared with you" β€” a CATEGORY where mail puts a who. The two are
 * different questions and they now have different functions; `kindLabel` keeps its own job.
 *
 * ⚠ THE FALLBACK IS BY KIND, NOT A BLANK. A notification queued before the server learned to
 * send `sender` still has to render a From column, and an empty one reads as a broken inbox
 * rather than as an old row β€” so it degrades to the honest machine name for its kind.
 */
export function senderOf(n: Notification): string {
  const sent = String(n.sender || "").trim();
  if (sent) return sent;
  if (n.kind === NOTIF_KIND_AUTOMATION) return "Automation";
  if (n.kind === NOTIF_KIND_SHARE) return "A teammate";
  return "Alerts";
}

/**
 * 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;
}