@remit/web-client 0.0.174 → 0.0.175

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.174",
3
+ "version": "0.0.175",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -31,6 +31,7 @@ import { IntelligencePane } from "@/components/mail/IntelligencePane";
31
31
  import { MessageToolbar } from "@/components/mail/MessageToolbar";
32
32
  import type { OpenMessageOptions } from "@/components/mail/ThreadListInteraction";
33
33
  import { useDeleteMessages } from "@/hooks/useDeleteMessages";
34
+ import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
34
35
  import { useToggleReadFor } from "@/hooks/useMarkAsRead";
35
36
  import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
36
37
  import { useThreadRow } from "@/hooks/useThreadRow";
@@ -408,7 +409,7 @@ function BriefPhone() {
408
409
  previousThread,
409
410
  handleDeselectIfRemoved,
410
411
  } = useBriefPane();
411
- const { intelligenceOpen, onToggleIntelligence } = useMailContext();
412
+ const drawer = useIntelligenceDrawer(conversation?.threadId ?? null);
412
413
 
413
414
  if (conversation) {
414
415
  return (
@@ -420,21 +421,21 @@ function BriefPhone() {
420
421
  selectedMessageId={conversation.messageId}
421
422
  authenticity={conversation.authenticity}
422
423
  onBack={onCloseThread}
423
- onOpenIntelligence={onToggleIntelligence}
424
+ onOpenIntelligence={drawer.toggle}
424
425
  onSwipeNext={nextThread ? () => onOpenThread(nextThread) : undefined}
425
426
  onSwipePrevious={
426
427
  previousThread ? () => onOpenThread(previousThread) : undefined
427
428
  }
428
- mobileIntelligenceOpen={intelligenceOpen}
429
+ mobileIntelligenceOpen={drawer.isOpen}
429
430
  />
430
431
  <Drawer
431
- isOpen={intelligenceOpen}
432
- onClose={onToggleIntelligence}
432
+ isOpen={drawer.isOpen}
433
+ onClose={drawer.close}
433
434
  ariaLabel="Message details"
434
435
  side="right"
435
436
  >
436
437
  <IntelligencePane
437
- onClose={onToggleIntelligence}
438
+ onClose={drawer.close}
438
439
  thread={selectedThread}
439
440
  mailboxId={selectedThread?.mailboxId}
440
441
  accountId={selectedThread?.accountId}
@@ -41,6 +41,7 @@ import { IntelligencePane } from "@/components/mail/IntelligencePane";
41
41
  import { MessageToolbar } from "@/components/mail/MessageToolbar";
42
42
  import type { OpenMessageOptions } from "@/components/mail/ThreadListInteraction";
43
43
  import { useDeleteMessages } from "@/hooks/useDeleteMessages";
44
+ import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
44
45
  import { useToggleReadFor } from "@/hooks/useMarkAsRead";
45
46
  import { useStarredThreads } from "@/hooks/useStarredThreads";
46
47
  import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
@@ -409,7 +410,7 @@ function FlaggedPhone() {
409
410
  previousThread,
410
411
  handleDeselectIfRemoved,
411
412
  } = useFlaggedPane();
412
- const { intelligenceOpen, onToggleIntelligence } = useMailContext();
413
+ const drawer = useIntelligenceDrawer(conversation?.threadId ?? null);
413
414
 
414
415
  if (conversation) {
415
416
  return (
@@ -421,21 +422,21 @@ function FlaggedPhone() {
421
422
  selectedMessageId={conversation.messageId}
422
423
  authenticity={conversation.authenticity}
423
424
  onBack={onCloseThread}
424
- onOpenIntelligence={onToggleIntelligence}
425
+ onOpenIntelligence={drawer.toggle}
425
426
  onSwipeNext={nextThread ? () => onOpenThread(nextThread) : undefined}
426
427
  onSwipePrevious={
427
428
  previousThread ? () => onOpenThread(previousThread) : undefined
428
429
  }
429
- mobileIntelligenceOpen={intelligenceOpen}
430
+ mobileIntelligenceOpen={drawer.isOpen}
430
431
  />
431
432
  <Drawer
432
- isOpen={intelligenceOpen}
433
- onClose={onToggleIntelligence}
433
+ isOpen={drawer.isOpen}
434
+ onClose={drawer.close}
434
435
  ariaLabel="Message details"
435
436
  side="right"
436
437
  >
437
438
  <IntelligencePane
438
- onClose={onToggleIntelligence}
439
+ onClose={drawer.close}
439
440
  thread={selectedThread}
440
441
  mailboxId={selectedThread?.mailboxId}
441
442
  accountId={selectedThread?.accountId}
@@ -72,6 +72,7 @@ import {
72
72
  } from "@/hooks/useDeleteMessages";
73
73
  import type { EscalationSearchQuery } from "@/hooks/useEscalatedActions";
74
74
  import { useIntelligenceData } from "@/hooks/useIntelligenceData";
75
+ import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
75
76
  import { useLayoutTier } from "@/hooks/useLayoutTier";
76
77
  import { useMailboxAccount } from "@/hooks/useMailboxAccount";
77
78
  import { useToggleReadFor } from "@/hooks/useMarkAsRead";
@@ -507,23 +508,6 @@ function MailboxPaneProvider({
507
508
  const focusedThread =
508
509
  threads.find((t) => t.messageId === triageFocusedId) ?? selectedThread;
509
510
 
510
- // Auto-open intelligence pane on DKIM mismatch.
511
- const autoOpenedForRef = useRef<string | null>(null);
512
- useEffect(() => {
513
- const id = selectedThread?.messageId ?? null;
514
- if (!id) return;
515
- if (autoOpenedForRef.current === id) return;
516
- if (selectedThread?.authenticity?.dkimMismatch) {
517
- autoOpenedForRef.current = id;
518
- if (!intelligenceOpen) onToggleIntelligence();
519
- }
520
- }, [
521
- selectedThread?.messageId,
522
- selectedThread?.authenticity?.dkimMismatch,
523
- intelligenceOpen,
524
- onToggleIntelligence,
525
- ]);
526
-
527
511
  // The mailbox's own unseen total. A count over the loaded pages undercounts
528
512
  // every mailbox larger than one page and creeps upward as the user scrolls,
529
513
  // so there is no fallback: until the mailbox resolves there is no number.
@@ -1085,41 +1069,41 @@ function MailboxReading() {
1085
1069
  const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
1086
1070
  const hasThread = Boolean(conversation);
1087
1071
 
1088
- // The drawer is modal, so it opens only when it is asked for — and only for
1089
- // the thread it was asked for. `intelligenceOpen` is the rail's persisted
1090
- // preference and the DKIM auto-open sets it on every tier, so driving the
1091
- // drawer from it would throw a scrim over a message the moment one was
1092
- // selected. Naming the thread is also what closes it again when the reader
1093
- // moves on: a bare flag would still be set when they came back.
1094
- const [drawerThreadId, setDrawerThreadId] = useState<string | null>(null);
1095
- const openThreadId = conversation?.threadId ?? null;
1096
- // Derived rather than stored: the drawer is up only while the thread it was
1097
- // opened for is still the one on screen, so moving to another one closes it
1098
- // with no effect to run. Closing it from an effect would paint one frame of
1099
- // an open drawer over the newly opened thread first.
1100
- const drawerOpen =
1101
- !railFits && openThreadId !== null && drawerThreadId === openThreadId;
1102
-
1103
- const closeIntelligenceDrawer = useCallback(
1104
- () => setDrawerThreadId(null),
1105
- [],
1106
- );
1107
- const openIntelligenceDrawer = useCallback(
1108
- () => setDrawerThreadId(openThreadId),
1109
- [openThreadId],
1110
- );
1111
- // The banner's "Why?" always an open, never a close.
1112
- const openIntelligence = railFits
1113
- ? onToggleIntelligence
1114
- : openIntelligenceDrawer;
1072
+ // The rail opens itself on a DKIM mismatch. It lives here, behind the rail's
1073
+ // own width gate, because raising it writes `#intelligence` into the address
1074
+ // and a panel the address names with no renderer behind it is a panel that
1075
+ // opens nothing (`docs/architecture/url-state.md`, R6). Below this width the
1076
+ // banner is the announcement and its "Why?" is the way in.
1077
+ const autoOpenedForRef = useRef<string | null>(null);
1078
+ useEffect(() => {
1079
+ if (!railFits) return;
1080
+ const id = selectedThread?.messageId ?? null;
1081
+ if (!id) return;
1082
+ if (autoOpenedForRef.current === id) return;
1083
+ if (!selectedThread?.authenticity?.dkimMismatch) return;
1084
+ autoOpenedForRef.current = id;
1085
+ if (!intelligenceOpen) onToggleIntelligence();
1086
+ }, [
1087
+ railFits,
1088
+ selectedThread?.messageId,
1089
+ selectedThread?.authenticity?.dkimMismatch,
1090
+ intelligenceOpen,
1091
+ onToggleIntelligence,
1092
+ ]);
1093
+
1094
+ const drawer = useIntelligenceDrawer(conversation?.threadId ?? null);
1095
+ const drawerOpen = !railFits && drawer.isOpen;
1096
+ const closeIntelligenceDrawer = drawer.close;
1097
+ const openIntelligence = railFits ? onToggleIntelligence : drawer.open;
1115
1098
  // The toolbar's control, which toggles whichever surface this width has.
1099
+ const { toggle: toggleDrawer } = drawer;
1116
1100
  const toggleIntelligence = useCallback(() => {
1117
1101
  if (railFits) {
1118
1102
  onToggleIntelligence();
1119
1103
  return;
1120
1104
  }
1121
- setDrawerThreadId(drawerOpen ? null : openThreadId);
1122
- }, [railFits, onToggleIntelligence, drawerOpen, openThreadId]);
1105
+ toggleDrawer();
1106
+ }, [railFits, onToggleIntelligence, toggleDrawer]);
1123
1107
  const intelligenceShowing =
1124
1108
  hasThread && (railFits ? intelligenceOpen : drawerOpen);
1125
1109
 
@@ -1219,13 +1203,12 @@ function MailboxPhone() {
1219
1203
  selectedThread,
1220
1204
  conversation,
1221
1205
  onOpenThread,
1222
- intelligenceOpen,
1223
- onToggleIntelligence,
1224
1206
  onBack,
1225
1207
  nextThread,
1226
1208
  previousThread,
1227
1209
  handleDeselectIfRemoved,
1228
1210
  } = useMailboxPane();
1211
+ const drawer = useIntelligenceDrawer(conversation?.threadId ?? null);
1229
1212
 
1230
1213
  if (conversation) {
1231
1214
  return (
@@ -1237,21 +1220,21 @@ function MailboxPhone() {
1237
1220
  selectedMessageId={conversation.messageId}
1238
1221
  authenticity={conversation.authenticity}
1239
1222
  onBack={onBack}
1240
- onOpenIntelligence={onToggleIntelligence}
1223
+ onOpenIntelligence={drawer.toggle}
1241
1224
  onSwipeNext={nextThread ? () => onOpenThread(nextThread) : undefined}
1242
1225
  onSwipePrevious={
1243
1226
  previousThread ? () => onOpenThread(previousThread) : undefined
1244
1227
  }
1245
- mobileIntelligenceOpen={intelligenceOpen}
1228
+ mobileIntelligenceOpen={drawer.isOpen}
1246
1229
  />
1247
1230
  <Drawer
1248
- isOpen={intelligenceOpen}
1249
- onClose={onToggleIntelligence}
1231
+ isOpen={drawer.isOpen}
1232
+ onClose={drawer.close}
1250
1233
  ariaLabel="Message details"
1251
1234
  side="right"
1252
1235
  >
1253
1236
  <IntelligencePane
1254
- onClose={onToggleIntelligence}
1237
+ onClose={drawer.close}
1255
1238
  thread={selectedThread}
1256
1239
  mailboxId={mailboxId}
1257
1240
  accountId={mailboxAccountId}
@@ -4,20 +4,20 @@ import { ChevronLeft, Info } from "lucide-react";
4
4
  import { useState } from "react";
5
5
  import { expect, userEvent, within } from "storybook/test";
6
6
  import { Drawer } from "@/components/layout/Drawer";
7
- import { resolveRailOpen } from "@/lib/intelligence-pref";
8
- import { parseOpenPanels, retainOpenPanelsAtTier } from "@/routing";
7
+ import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
9
8
 
10
9
  /**
11
- * The intelligence drawer below the desktop tier (#777).
10
+ * The intelligence drawer below the desktop tier (#777, #778).
12
11
  *
13
12
  * There is no fourth pane here: the rail is a full-screen drawer over the open
14
- * message, so it belongs to the thread it was opened for. The address is the
15
- * only place its open state lives, and a navigation drops itotherwise a DKIM
16
- * mismatch on one message leaves the drawer covering every message opened
17
- * after it.
13
+ * message, so it belongs to the thread it was opened for and to nothing else.
14
+ * It is modal, so it opens only when the reader asks — a DKIM mismatch shows
15
+ * the banner and leaves the message alone. A drawer that opened itself would
16
+ * put a scrim over the message, and the reader's next tap would land on the
17
+ * scrim rather than on Back.
18
18
  *
19
- * Both rules are the app's own: the fragment goes through
20
- * `retainOpenPanelsAtTier`, and what is up is `resolveRailOpen`.
19
+ * The rule is the app's own: `useIntelligenceDrawer` is the hook the phone and
20
+ * mid-width panes both run on.
21
21
  */
22
22
  const meta: Meta = {
23
23
  title: "Flows/Reading/Intelligence Drawer",
@@ -30,8 +30,6 @@ type Story = StoryObj;
30
30
  const PHONE_WIDTH = 390;
31
31
  const PHONE_HEIGHT = 720;
32
32
 
33
- const retainOnPhone = retainOpenPanelsAtTier(false);
34
-
35
33
  const intelligence: IntelligenceData = {
36
34
  sender: {
37
35
  name: "Mondial Relay",
@@ -77,23 +75,14 @@ const messages: readonly Message[] = [
77
75
  ];
78
76
 
79
77
  /**
80
- * The phone reader, driven by the address alone: `hash` is the whole state,
81
- * and every navigation writes it through the same helper the panes use.
78
+ * The phone reader. What is open is the thread on screen and, beside it, the
79
+ * drawer the reader asked for held by `useIntelligenceDrawer` against that
80
+ * thread, so opening another one takes the drawer down with it.
82
81
  */
83
- const PhoneReader = ({ initialHash = "" }: { initialHash?: string }) => {
84
- const [hash, setHash] = useState(initialHash);
82
+ const PhoneReader = () => {
85
83
  const [openId, setOpenId] = useState<string | undefined>(messages[0]?.id);
86
84
  const open = messages.find((message) => message.id === openId);
87
- const drawerOpen = resolveRailOpen({
88
- panels: parseOpenPanels(hash),
89
- prefersOpen: true,
90
- isDesktop: false,
91
- hasThread: open !== undefined,
92
- });
93
- const navigate = (nextId: string | undefined) => {
94
- setHash(retainOnPhone(hash));
95
- setOpenId(nextId);
96
- };
85
+ const drawer = useIntelligenceDrawer(open?.id ?? null);
97
86
 
98
87
  return (
99
88
  <div className="flex flex-col gap-2">
@@ -113,7 +102,7 @@ const PhoneReader = ({ initialHash = "" }: { initialHash?: string }) => {
113
102
  <button
114
103
  type="button"
115
104
  aria-label="Back"
116
- onClick={() => navigate(undefined)}
105
+ onClick={() => setOpenId(undefined)}
117
106
  className="inline-flex size-11 items-center justify-center rounded-md text-fg hover:bg-surface-raised"
118
107
  >
119
108
  <ChevronLeft className="size-5" />
@@ -124,7 +113,7 @@ const PhoneReader = ({ initialHash = "" }: { initialHash?: string }) => {
124
113
  <button
125
114
  type="button"
126
115
  aria-label="Message details"
127
- onClick={() => setHash("intelligence")}
116
+ onClick={drawer.toggle}
128
117
  className="inline-flex size-11 items-center justify-center rounded-md text-fg hover:bg-surface-raised"
129
118
  >
130
119
  <Info className="size-5" />
@@ -141,7 +130,7 @@ const PhoneReader = ({ initialHash = "" }: { initialHash?: string }) => {
141
130
  <li key={message.id}>
142
131
  <button
143
132
  type="button"
144
- onClick={() => navigate(message.id)}
133
+ onClick={() => setOpenId(message.id)}
145
134
  className="flex w-full flex-col items-start gap-0.5 border-b border-line px-4 py-3 text-left hover:bg-surface-raised"
146
135
  >
147
136
  <span className="text-sm font-semibold text-fg">
@@ -156,8 +145,8 @@ const PhoneReader = ({ initialHash = "" }: { initialHash?: string }) => {
156
145
  </ul>
157
146
  )}
158
147
  <Drawer
159
- isOpen={drawerOpen}
160
- onClose={() => setHash("")}
148
+ isOpen={drawer.isOpen}
149
+ onClose={drawer.close}
161
150
  ariaLabel="Message details"
162
151
  side="right"
163
152
  >
@@ -165,34 +154,58 @@ const PhoneReader = ({ initialHash = "" }: { initialHash?: string }) => {
165
154
  </Drawer>
166
155
  </div>
167
156
  <p className="font-mono text-2xs text-fg-subtle">
168
- {`/mail/inbox${open ? `/${open.id}` : ""}${hash ? `#${hash}` : ""}`}
157
+ {`/mail/inbox${open ? `/${open.id}` : ""}`}
169
158
  </p>
170
159
  </div>
171
160
  );
172
161
  };
173
162
 
174
- /** A message with no drawer over it: the address names no panel. */
175
- export const Closed: Story = {
176
- render: () => <PhoneReader />,
163
+ /** The reader's own press on the details control. */
164
+ const openDetails = async (canvasElement: HTMLElement): Promise<void> => {
165
+ await userEvent.click(
166
+ within(canvasElement).getByLabelText("Message details"),
167
+ );
177
168
  };
178
169
 
179
170
  /**
180
- * The drawer over the message it was opened for here by a DKIM mismatch,
181
- * which opens it without being asked.
171
+ * The warned message as it opens: the banner's own wording, and nothing over
172
+ * it. A mismatch is what the panel explains, never a reason to cover the
173
+ * message with it — a scrim there takes the reader's next tap, Back included.
182
174
  */
175
+ export const Closed: Story = {
176
+ render: () => <PhoneReader />,
177
+ play: async ({ canvasElement }) => {
178
+ const canvas = within(canvasElement);
179
+ await expect(canvas.queryByRole("dialog")).toBeNull();
180
+ await expect(canvas.getByLabelText("Back")).toBeVisible();
181
+ },
182
+ };
183
+
184
+ /** The drawer over the message it was opened for. */
183
185
  export const OpenOverThread: Story = {
184
- render: () => <PhoneReader initialHash="intelligence" />,
186
+ render: () => <PhoneReader />,
187
+ play: async ({ canvasElement }) => {
188
+ await openDetails(canvasElement);
189
+ await expect(within(canvasElement).getByRole("dialog")).toBeVisible();
190
+ },
185
191
  };
186
192
 
187
193
  /**
188
- * The same drawer, then Back and another message. The fragment is dropped on
189
- * the way out, so the second message is not covered by a panel opened for the
190
- * first. Press Back with the drawer up to walk it.
194
+ * Dismissed, and gone for the next message too: the drawer is held against the
195
+ * thread it was opened for, so nothing carries it forward.
191
196
  */
192
- export const DroppedByNavigation: Story = {
193
- render: () => <PhoneReader initialHash="intelligence" />,
197
+ export const DismissedAndNotCarried: Story = {
198
+ render: () => <PhoneReader />,
194
199
  play: async ({ canvasElement }) => {
195
200
  const canvas = within(canvasElement);
201
+ await openDetails(canvasElement);
202
+ await expect(canvas.getByRole("dialog")).toBeVisible();
203
+
204
+ // The scrim carries the same name as the header's close button and comes
205
+ // first in the drawer, so the visible control is the second of the two.
206
+ await userEvent.click(canvas.getAllByLabelText("Close menu")[1]);
207
+ await expect(canvas.queryByRole("dialog")).toBeNull();
208
+
196
209
  await userEvent.click(canvas.getByLabelText("Back"));
197
210
  await userEvent.click(canvas.getByText("Standup moved to 10:15"));
198
211
  await expect(canvas.queryByRole("dialog")).toBeNull();
@@ -204,5 +217,8 @@ export const DroppedByNavigation: Story = {
204
217
  export const OpenOverThreadDark: Story = {
205
218
  name: "Open Over Thread (dark)",
206
219
  parameters: { theme: "dark" },
207
- render: () => <PhoneReader initialHash="intelligence" />,
220
+ render: () => <PhoneReader />,
221
+ play: async ({ canvasElement }) => {
222
+ await openDetails(canvasElement);
223
+ },
208
224
  };
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The intelligence drawer is modal, so what matters as much as opening it is
3
+ * that nothing else can. It belongs to the thread it was opened for: leaving
4
+ * that thread puts it away, and coming back to the same message opens it clear
5
+ * rather than under the scrim it was last left with (#778).
6
+ *
7
+ * The reachable case is a phone's system Back, which is a navigation like any
8
+ * other — the thread goes, and the drawer has to go with it for good.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { afterEach, beforeEach, describe, it } from "node:test";
13
+ import { createElement } from "react";
14
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
15
+ import { useIntelligenceDrawer } from "./useIntelligenceDrawer";
16
+
17
+ let dom: DomHarness;
18
+
19
+ beforeEach(() => {
20
+ dom = createDomHarness();
21
+ });
22
+
23
+ afterEach(() => {
24
+ dom.close();
25
+ });
26
+
27
+ /** Reports what the drawer says, and offers the controls a reader has. */
28
+ const Probe = ({ threadId }: { threadId: string | null }) => {
29
+ const drawer = useIntelligenceDrawer(threadId);
30
+ return createElement(
31
+ "div",
32
+ null,
33
+ createElement(
34
+ "span",
35
+ { "data-state": "" },
36
+ drawer.isOpen ? "open" : "shut",
37
+ ),
38
+ createElement("button", {
39
+ type: "button",
40
+ "data-open": "",
41
+ onClick: drawer.open,
42
+ }),
43
+ createElement("button", {
44
+ type: "button",
45
+ "data-toggle": "",
46
+ onClick: drawer.toggle,
47
+ }),
48
+ );
49
+ };
50
+
51
+ const state = (): string | undefined =>
52
+ dom.query("[data-state]")?.textContent ?? undefined;
53
+ const press = (handle: string): void => {
54
+ const button = dom.query(`[${handle}]`);
55
+ if (!button) throw new Error(`no ${handle} control`);
56
+ dom.click(button);
57
+ };
58
+
59
+ describe("the intelligence drawer", () => {
60
+ it("opens only when it is asked to", () => {
61
+ dom.render(createElement(Probe, { threadId: "thread-a" }));
62
+ assert.equal(state(), "shut");
63
+
64
+ press("data-open");
65
+ assert.equal(state(), "open");
66
+ });
67
+
68
+ it("does not come back with the message it was opened over", () => {
69
+ dom.render(createElement(Probe, { threadId: "thread-a" }));
70
+ press("data-open");
71
+ assert.equal(state(), "open");
72
+
73
+ // System Back: the thread closes under it.
74
+ dom.render(createElement(Probe, { threadId: null }));
75
+ assert.equal(state(), "shut");
76
+
77
+ // And the same message again, which is where the scrim used to be waiting.
78
+ dom.render(createElement(Probe, { threadId: "thread-a" }));
79
+ assert.equal(state(), "shut");
80
+ });
81
+
82
+ it("stays behind when the reader moves to another thread", () => {
83
+ dom.render(createElement(Probe, { threadId: "thread-a" }));
84
+ press("data-open");
85
+ assert.equal(state(), "open");
86
+
87
+ dom.render(createElement(Probe, { threadId: "thread-b" }));
88
+ assert.equal(state(), "shut");
89
+ });
90
+
91
+ it("toggles shut again from the toolbar's control", () => {
92
+ dom.render(createElement(Probe, { threadId: "thread-a" }));
93
+ press("data-toggle");
94
+ assert.equal(state(), "open");
95
+
96
+ press("data-toggle");
97
+ assert.equal(state(), "shut");
98
+ });
99
+ });
@@ -0,0 +1,54 @@
1
+ import { useCallback, useState } from "react";
2
+
3
+ /**
4
+ * The intelligence drawer's own state, scoped to the thread it was opened for.
5
+ *
6
+ * The drawer is modal, so it opens only when it is asked for. `intelligenceOpen`
7
+ * is the rail's persisted preference and the DKIM auto-open sets it on every
8
+ * tier, so driving the drawer from it throws a scrim over a message the moment
9
+ * one is selected — and the reader's next tap lands on that scrim instead of the
10
+ * control they aimed at, Back to messages included. Naming the thread is also
11
+ * what closes it again when they move on: a bare flag would still be set when
12
+ * they came back.
13
+ */
14
+ export interface IntelligenceDrawer {
15
+ /** Up only while the thread it was opened for is the one on screen. */
16
+ isOpen: boolean;
17
+ /** The banner's "Why?" — always an open, never a close. */
18
+ open: () => void;
19
+ close: () => void;
20
+ /** The toolbar's control. */
21
+ toggle: () => void;
22
+ }
23
+
24
+ export function useIntelligenceDrawer(
25
+ openThreadId: string | null,
26
+ ): IntelligenceDrawer {
27
+ const [drawerThreadId, setDrawerThreadId] = useState<string | null>(null);
28
+ // Leaving the thread puts the drawer away for good, rather than leaving it
29
+ // armed for the reader's return. Held state alone would reopen it: press
30
+ // system Back and come to the same message again and the scrim would be
31
+ // waiting, which is the defect this hook exists to end (#778).
32
+ //
33
+ // Adjusted during render, not in an effect: React re-runs the render before
34
+ // committing, so nothing paints with the stale thread. An effect would show
35
+ // one frame of an open drawer over the message first.
36
+ if (drawerThreadId !== null && drawerThreadId !== openThreadId) {
37
+ setDrawerThreadId(null);
38
+ }
39
+ // Derived rather than stored: moving to another thread closes it with no
40
+ // effect to run.
41
+ const isOpen = openThreadId !== null && drawerThreadId === openThreadId;
42
+
43
+ const close = useCallback(() => setDrawerThreadId(null), []);
44
+ const open = useCallback(
45
+ () => setDrawerThreadId(openThreadId),
46
+ [openThreadId],
47
+ );
48
+ const toggle = useCallback(
49
+ () => setDrawerThreadId(isOpen ? null : openThreadId),
50
+ [isOpen, openThreadId],
51
+ );
52
+
53
+ return { isOpen, open, close, toggle };
54
+ }