File size: 21,115 Bytes
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
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
// ---------------------------------------------------------------------------
// 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 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&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>
  );
}