@remit/web-client 0.0.186 → 0.0.188

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.186",
3
+ "version": "0.0.188",
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": {
@@ -72,7 +72,11 @@ import {
72
72
  import type { EscalationSearchQuery } from "@/hooks/useEscalatedActions";
73
73
  import { useIntelligenceData } from "@/hooks/useIntelligenceData";
74
74
  import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
75
- import { useIntelligenceSurface } from "@/hooks/useIntelligenceSurface";
75
+ import {
76
+ type IntelligenceCommands,
77
+ useIntelligenceSurface,
78
+ usePublishIntelligenceCommands,
79
+ } from "@/hooks/useIntelligenceSurface";
76
80
  import { useLayoutTier } from "@/hooks/useLayoutTier";
77
81
  import { useMailboxAccount } from "@/hooks/useMailboxAccount";
78
82
  import { useToggleReadFor } from "@/hooks/useMarkAsRead";
@@ -168,8 +172,14 @@ interface MailboxPaneContextValue {
168
172
  * when no category is selected.
169
173
  */
170
174
  listFilter: MessageListFilter | undefined;
171
- intelligenceOpen: boolean;
172
175
  onToggleIntelligence: () => void;
176
+ /**
177
+ * Where the mounted reading surface publishes the intelligence commands the
178
+ * keyboard layer drives. The rail's width gate is the shell's own
179
+ * measurement and this provider sits above the shell, so the surface that is
180
+ * mounted answers for its own tier rather than this one guessing.
181
+ */
182
+ intelligenceRef: RefObject<IntelligenceCommands | null>;
173
183
  /**
174
184
  * Deselects the open message when it's the one a mutation just removed
175
185
  * from this mailbox's list — wired into every mutation that can take the
@@ -260,8 +270,7 @@ function MailboxPaneProvider({
260
270
  const threadId = thread?.threadId;
261
271
  const pointedAtMessageId = thread?.messageId;
262
272
  const telemetry = useTelemetry();
263
- const { accounts, searchQuery, intelligenceOpen, onToggleIntelligence } =
264
- useMailContext();
273
+ const { accounts, searchQuery, onToggleIntelligence } = useMailContext();
265
274
  const tokenContext = useSearchTokenContext();
266
275
 
267
276
  const normalizedSearchQuery = normalizeSearchQuery(searchQuery);
@@ -499,6 +508,8 @@ function MailboxPaneProvider({
499
508
  onAfterOptimisticRemove: handleDeselectIfRemoved,
500
509
  });
501
510
 
511
+ const intelligenceRef = useRef<IntelligenceCommands | null>(null);
512
+
502
513
  const triage = useTriageContext();
503
514
  const {
504
515
  listCommandsRef,
@@ -691,9 +702,14 @@ function MailboxPaneProvider({
691
702
  updateFocusedSenderFlags({ vip: { value: next } });
692
703
  }, [focusedAddressId, focusedAddress, updateFocusedSenderFlags]);
693
704
 
705
+ // Block sender lives in intelligence, so the key raises whichever surface
706
+ // this width has: the rail above 1280, the drawer below it. Reaching for
707
+ // `onToggleIntelligence` from here wrote `#intelligence` at every tier, and
708
+ // a panel the address names with no renderer behind it is a panel that opens
709
+ // nothing (`docs/architecture/url-state.md`, R6).
694
710
  const triageBlock = useCallback(() => {
695
- if (!intelligenceOpen) onToggleIntelligence();
696
- }, [intelligenceOpen, onToggleIntelligence]);
711
+ intelligenceRef.current?.open();
712
+ }, []);
697
713
 
698
714
  const goToRoute = useCallback(
699
715
  (to: "/mail/brief" | "/mail/flagged" | "/settings") => {
@@ -736,10 +752,12 @@ function MailboxPaneProvider({
736
752
  toggleStar: triageStar,
737
753
  toggleRead: triageToggleRead,
738
754
  muteSender: triageMute,
739
- blockSender: triageBlock,
755
+ blockSender: selectedThread ? triageBlock : undefined,
740
756
  vipSender: triageVip,
741
757
  markJunk: triageMarkJunk,
742
- toggleIntelligence: selectedThread ? onToggleIntelligence : undefined,
758
+ toggleIntelligence: selectedThread
759
+ ? () => intelligenceRef.current?.toggle()
760
+ : undefined,
743
761
  compose: openCompose,
744
762
  goBrief: () => goToRoute("/mail/brief"),
745
763
  goInbox: () => goToRoute("/mail/brief"),
@@ -783,8 +801,8 @@ function MailboxPaneProvider({
783
801
  onToggleFilterAttribute,
784
802
  onClearFilters,
785
803
  listFilter,
786
- intelligenceOpen,
787
804
  onToggleIntelligence,
805
+ intelligenceRef,
788
806
  handleDeselectIfRemoved,
789
807
  // Escalation ("select all N matching") re-issues this predicate server-side
790
808
  // and acts on every match, so it is only offered when the predicate IS the
@@ -1064,9 +1082,11 @@ function MailboxReading() {
1064
1082
  onToolbarStar,
1065
1083
  onToolbarMove,
1066
1084
  handleDeselectIfRemoved,
1085
+ intelligenceRef,
1067
1086
  } = useMailboxPane();
1068
1087
  const hasThread = Boolean(conversation);
1069
1088
  const intelligence = useIntelligenceSurface(conversation?.threadId);
1089
+ usePublishIntelligenceCommands(intelligenceRef, intelligence);
1070
1090
 
1071
1091
  // The rail opens itself on a DKIM mismatch. It lives here, behind the rail's
1072
1092
  // own width gate, because raising it writes `#intelligence` into the address
@@ -1182,8 +1202,14 @@ function MailboxPhone() {
1182
1202
  nextThread,
1183
1203
  previousThread,
1184
1204
  handleDeselectIfRemoved,
1205
+ intelligenceRef,
1185
1206
  } = useMailboxPane();
1207
+ // The drawer directly, not `useIntelligenceSurface`: this view is what the
1208
+ // shell mounts where it has one pane, so there is no rail to choose between
1209
+ // — and the rail's own width gate answers yes on a wide portrait tablet the
1210
+ // shell still put here.
1186
1211
  const drawer = useIntelligenceDrawer(conversation?.threadId ?? null);
1212
+ usePublishIntelligenceCommands(intelligenceRef, drawer);
1187
1213
 
1188
1214
  if (conversation) {
1189
1215
  return (
@@ -2,7 +2,6 @@ import {
2
2
  messageBulkOperationsUpdateFlagsMutation,
3
3
  threadDetailOperationsListThreadMessagesQueryKey,
4
4
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
- import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
6
5
  import { useMutation, useQueryClient } from "@tanstack/react-query";
7
6
  import { useNavigate } from "@tanstack/react-router";
8
7
  import {
@@ -32,29 +31,13 @@ import {
32
31
  patchThreadListQueries,
33
32
  restoreThreadListQueries,
34
33
  snapshotThreadListQueries,
35
- type ThreadListSnapshotEntry,
34
+ type ThreadMessagesData,
35
+ type ThreadMutationContext,
36
36
  threadListCacheKeys,
37
37
  } from "@/lib/thread-list-cache";
38
38
  import { useOpenThreadPath, useRetainOpenPanels } from "@/routing";
39
39
  import { MoveToTrigger } from "./MoveToTrigger";
40
40
 
41
- interface ThreadMessagesData {
42
- items: RemitImapThreadMessageResponse[];
43
- [key: string]: unknown;
44
- }
45
-
46
- interface SnapshotEntry<T> {
47
- queryKey: readonly unknown[];
48
- data: T;
49
- }
50
-
51
- interface MarkUnreadContext {
52
- threadMessagesPrefix: readonly unknown[];
53
- listPrefixes: ReadonlyArray<readonly unknown[]>;
54
- previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
55
- previousThreadsList: ThreadListSnapshotEntry[];
56
- }
57
-
58
41
  interface MessageActionMenuProps {
59
42
  messageId: string;
60
43
  threadId: string;
@@ -109,7 +92,7 @@ export const MessageActionMenu = ({
109
92
 
110
93
  const { mutate: updateFlags, isPending: isUpdatingFlags } = useMutation({
111
94
  ...messageBulkOperationsUpdateFlagsMutation(),
112
- onMutate: async (variables): Promise<MarkUnreadContext> => {
95
+ onMutate: async (variables): Promise<ThreadMutationContext> => {
113
96
  const isReadNext = variables.body.isRead ?? true;
114
97
  const targetIds = new Set(variables.body.messageIds ?? []);
115
98
 
@@ -0,0 +1,343 @@
1
+ /**
2
+ * The mailbox's intelligence keys act on the surface the width has (#840).
3
+ *
4
+ * `i` and `b` are registered by the pane provider, which sits above the shell
5
+ * and so cannot see the rail's own width gate. Both reached for the rail's
6
+ * toggle directly, and below 1280 that wrote `#intelligence` into the address
7
+ * with no rail mounted to render it — a panel the address names and nothing
8
+ * answers (`docs/architecture/url-state.md`, R6). The drawer is the surface at
9
+ * those widths, and it is what the toolbar's own control already used.
10
+ *
11
+ * Mounted in the real `AppShellSlotted` at all three tiers, with the `/mail`
12
+ * layout's binding between the fragment and the rail: which panes a width has
13
+ * is the shell's own answer here, and what the address holds is the router's.
14
+ */
15
+
16
+ import assert from "node:assert/strict";
17
+ import { afterEach, describe, it } from "node:test";
18
+ import { AppShellSlotted } from "@remit/ui";
19
+ import {
20
+ type AnyRouter,
21
+ createMemoryHistory,
22
+ createRootRoute,
23
+ createRoute,
24
+ createRouter,
25
+ Outlet,
26
+ RouterProvider,
27
+ } from "@tanstack/react-router";
28
+ import { createElement, type ReactNode, useCallback } from "react";
29
+ import { ComposeProvider } from "@/components/compose/ComposeProvider";
30
+ import { MailContext, type MailContextValue } from "@/lib/mail-context";
31
+ import { EMPTY_RESULT_FOLDER_INDEX } from "@/lib/result-folder";
32
+ import { useOpenPanels, useOpenThreadPath, useSetOpenPanels } from "@/routing";
33
+ import {
34
+ createDomHarness,
35
+ type DomHarness,
36
+ type DomOptions,
37
+ } from "@/test-support/dom";
38
+ import { makeThreadMessage } from "@/test-support/fixtures";
39
+ import { type HttpMock, mockFetch } from "@/test-support/http";
40
+ import {
41
+ describedMessage,
42
+ intelligenceDrawer,
43
+ MESSAGE_ID,
44
+ settle,
45
+ THREAD_ID,
46
+ } from "@/test-support/intelligence-surface";
47
+ import { MailboxPane } from "./MailboxPane";
48
+
49
+ /** Below the rail's 1280px gate, above the reading pane's 1024px one. */
50
+ const TWO_PANE_WIDTH = 1100;
51
+ /** Wide enough for the rail, which is where the fragment raises it. */
52
+ const RAIL_WIDTH = 1400;
53
+ /** One pane, where the list route mounts its phone view instead of the slots. */
54
+ const PHONE_WIDTH = 420;
55
+
56
+ const MAILBOX_ID = "mailbox-1";
57
+ const MESSAGE_PATH = `/mail/${MAILBOX_ID}/${THREAD_ID}/${MESSAGE_ID}`;
58
+
59
+ /**
60
+ * No DKIM mismatch: the auto-open raises the rail on its own where the rail
61
+ * fits, which would put `#intelligence` in the address before any key is
62
+ * pressed.
63
+ */
64
+ const row = makeThreadMessage({
65
+ messageId: MESSAGE_ID,
66
+ threadId: THREAD_ID,
67
+ subject: "Your parcel could not be delivered",
68
+ fromName: "Mondial Relay",
69
+ fromEmail: "delivery.notice@gmail.example",
70
+ });
71
+
72
+ let harness: DomHarness | undefined;
73
+ let http: HttpMock | undefined;
74
+
75
+ afterEach(() => {
76
+ harness?.close();
77
+ harness = undefined;
78
+ http?.restore();
79
+ http = undefined;
80
+ });
81
+
82
+ // The router reads `self` at construction; the shared jsdom globals stop at
83
+ // `window`.
84
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
85
+
86
+ /**
87
+ * What `routes/mail.tsx` binds: the rail's open state is the fragment, and the
88
+ * toggle rewrites the fragment. The device preference is left out — nothing
89
+ * here is about the tier the address is silent on.
90
+ */
91
+ function MailLayout({ children }: { children: ReactNode }) {
92
+ const openPanels = useOpenPanels();
93
+ const setOpenPanels = useSetOpenPanels();
94
+ const intelligenceOpen = openPanels.includes("intelligence");
95
+ const onToggleIntelligence = useCallback(() => {
96
+ setOpenPanels(intelligenceOpen ? [] : ["intelligence"]);
97
+ }, [intelligenceOpen, setOpenPanels]);
98
+
99
+ const value: MailContextValue = {
100
+ accounts: [],
101
+ mailboxNameIndex: new Map(),
102
+ accountNameIndex: new Map(),
103
+ resultFolderIndex: EMPTY_RESULT_FOLDER_INDEX,
104
+ searchQuery: "",
105
+ searchInput: "",
106
+ searchViewKey: "",
107
+ onSearchChange: () => {},
108
+ onSearchClear: () => {},
109
+ onSearchClearQuery: () => {},
110
+ intelligenceOpen,
111
+ onToggleIntelligence,
112
+ };
113
+ return createElement(MailContext.Provider, { value }, children);
114
+ }
115
+
116
+ /**
117
+ * The shell as `MailShell` mounts it: one pane carrying the phone view below
118
+ * the reading boundary, the slots above it, and the rail's visibility taken
119
+ * from the address the same way the layout takes it.
120
+ */
121
+ function Shell({ width }: { width: number }) {
122
+ const intelligenceOpen = useOpenPanels().includes("intelligence");
123
+ if (width < 1024) {
124
+ return createElement(AppShellSlotted, {
125
+ initialWidth: width,
126
+ nav: null,
127
+ list: createElement(MailboxPane.Phone),
128
+ });
129
+ }
130
+ return createElement(AppShellSlotted, {
131
+ initialWidth: width,
132
+ nav: null,
133
+ list: null,
134
+ reading: createElement(MailboxPane.Reading),
135
+ intelligence: createElement(MailboxPane.Intelligence),
136
+ intelligenceOpen,
137
+ });
138
+ }
139
+
140
+ const testRouter = (width: number, href: string): AnyRouter => {
141
+ const rootRoute = createRootRoute({
142
+ component: () =>
143
+ createElement(
144
+ ComposeProvider,
145
+ null,
146
+ createElement(MailLayout, {
147
+ // biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
148
+ children: createElement(Outlet),
149
+ }),
150
+ ),
151
+ });
152
+ const mailRoute = createRoute({
153
+ getParentRoute: () => rootRoute,
154
+ path: "/mail",
155
+ validateSearch: (search: Record<string, unknown>) => search,
156
+ component: Outlet,
157
+ });
158
+ const mailboxRoute = createRoute({
159
+ getParentRoute: () => mailRoute,
160
+ path: "/$mailboxId",
161
+ component: () =>
162
+ createElement(MailboxPane, {
163
+ mailboxId: MAILBOX_ID,
164
+ thread: useOpenThreadPath(),
165
+ // biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
166
+ children: createElement(Outlet),
167
+ }),
168
+ });
169
+ const threadRoute = createRoute({
170
+ getParentRoute: () => mailboxRoute,
171
+ path: "$threadId",
172
+ component: Outlet,
173
+ });
174
+ const messageRoute = createRoute({
175
+ getParentRoute: () => threadRoute,
176
+ path: "$messageId",
177
+ component: () => createElement(Shell, { width }),
178
+ });
179
+ const routeTree = rootRoute.addChildren([
180
+ mailRoute.addChildren([
181
+ mailboxRoute.addChildren([threadRoute.addChildren([messageRoute])]),
182
+ ]),
183
+ ]);
184
+ return createRouter({
185
+ routeTree,
186
+ history: createMemoryHistory({ initialEntries: [href] }),
187
+ }) as unknown as AnyRouter;
188
+ };
189
+
190
+ const mountAt = async (
191
+ width: number,
192
+ options: DomOptions = {},
193
+ href: string = MESSAGE_PATH,
194
+ ): Promise<[DomHarness, AnyRouter]> => {
195
+ http = mockFetch((call) => {
196
+ if (call.path.endsWith("/config")) return { accounts: [] };
197
+ if (call.path.endsWith(`/threads/${THREAD_ID}/messages`)) {
198
+ return { items: [row] };
199
+ }
200
+ if (call.path.includes("/messages/")) return describedMessage;
201
+ if (call.path.includes("/threads")) return { items: [row] };
202
+ return { items: [] };
203
+ });
204
+
205
+ const router = testRouter(width, href);
206
+ await router.load();
207
+ const mounted = createDomHarness({ viewportWidth: width, ...options });
208
+ harness = mounted;
209
+ mounted.renderApp(createElement(RouterProvider, { router }));
210
+ await settle(mounted);
211
+ return [mounted, router];
212
+ };
213
+
214
+ const press = async (mounted: DomHarness, key: string): Promise<void> => {
215
+ mounted.dispatch(
216
+ mounted.window,
217
+ new mounted.window.KeyboardEvent("keydown", { key, bubbles: true }),
218
+ );
219
+ await settle(mounted);
220
+ };
221
+
222
+ /**
223
+ * The rail: the intelligence panel mounted in the shell's own row rather than
224
+ * inside the drawer, which puts the same panel behind a scrim.
225
+ */
226
+ const rail = (mounted: DomHarness): HTMLElement | null => {
227
+ const pane = mounted.query("aside");
228
+ if (!pane) return null;
229
+ return pane.closest('[role="dialog"]') ? null : pane;
230
+ };
231
+
232
+ describe("the intelligence keys reach the surface the width has (#840)", () => {
233
+ for (const [tier, width, options] of [
234
+ ["between the reading pane and the rail", TWO_PANE_WIDTH, {}],
235
+ [
236
+ "on the phone",
237
+ PHONE_WIDTH,
238
+ { pointer: "coarse", orientation: "portrait" } as DomOptions,
239
+ ],
240
+ ] as const) {
241
+ it(`${tier}, i opens the drawer and leaves the address alone`, async () => {
242
+ const [mounted, router] = await mountAt(width, options);
243
+
244
+ await press(mounted, "i");
245
+
246
+ assert.ok(intelligenceDrawer(mounted), "pressing i opened nothing");
247
+ assert.equal(
248
+ router.state.location.hash,
249
+ "",
250
+ "the address named a panel this width cannot render",
251
+ );
252
+ });
253
+
254
+ it(`${tier}, a second i puts the drawer away`, async () => {
255
+ const [mounted] = await mountAt(width, options);
256
+
257
+ await press(mounted, "i");
258
+ assert.ok(intelligenceDrawer(mounted), "pressing i opened nothing");
259
+
260
+ await press(mounted, "i");
261
+ assert.equal(
262
+ intelligenceDrawer(mounted),
263
+ null,
264
+ "the second press left it up",
265
+ );
266
+ });
267
+
268
+ it(`${tier}, b reaches block sender through the same drawer`, async () => {
269
+ const [mounted, router] = await mountAt(width, options);
270
+
271
+ await press(mounted, "b");
272
+
273
+ assert.ok(intelligenceDrawer(mounted), "pressing b opened nothing");
274
+ assert.equal(
275
+ router.state.location.hash,
276
+ "",
277
+ "the address named a panel this width cannot render",
278
+ );
279
+ });
280
+ }
281
+
282
+ it("still writes the fragment where the rail is the surface", async () => {
283
+ const [mounted, router] = await mountAt(RAIL_WIDTH);
284
+
285
+ await press(mounted, "i");
286
+
287
+ assert.equal(
288
+ router.state.location.hash,
289
+ "intelligence",
290
+ "the rail's own tier stopped writing the fragment",
291
+ );
292
+ assert.equal(
293
+ intelligenceDrawer(mounted),
294
+ null,
295
+ "the drawer came up where the rail is the surface",
296
+ );
297
+ });
298
+
299
+ it("writes the fragment for block sender where the rail is the surface", async () => {
300
+ const [mounted, router] = await mountAt(RAIL_WIDTH);
301
+
302
+ await press(mounted, "b");
303
+
304
+ assert.equal(router.state.location.hash, "intelligence");
305
+ assert.equal(
306
+ intelligenceDrawer(mounted),
307
+ null,
308
+ "the drawer came up where the rail is the surface",
309
+ );
310
+ });
311
+ });
312
+
313
+ /**
314
+ * What the fragment is worth at each width, on a cold load. The rail is the one
315
+ * renderer the name has, which is why the keys above must not write the name
316
+ * anywhere else.
317
+ */
318
+ describe("#intelligence names a renderer only where the rail fits", () => {
319
+ it("mounts the rail where the address carries it and the width has room", async () => {
320
+ const [mounted] = await mountAt(
321
+ RAIL_WIDTH,
322
+ {},
323
+ `${MESSAGE_PATH}#intelligence`,
324
+ );
325
+
326
+ assert.ok(rail(mounted), "the fragment raised no rail");
327
+ });
328
+
329
+ it("mounts nothing for the same address below the rail's width", async () => {
330
+ const [mounted] = await mountAt(
331
+ TWO_PANE_WIDTH,
332
+ {},
333
+ `${MESSAGE_PATH}#intelligence`,
334
+ );
335
+
336
+ assert.equal(rail(mounted), null, "a rail rendered where none fits");
337
+ assert.equal(
338
+ intelligenceDrawer(mounted),
339
+ null,
340
+ "the fragment raised the drawer, which belongs to the thread",
341
+ );
342
+ });
343
+ });
@@ -32,15 +32,19 @@ import { type OpenThreadPath, useOpenThreadPath } from "@/routing";
32
32
  import { createDomHarness, type DomHarness } from "@/test-support/dom";
33
33
  import { makeThreadMessage } from "@/test-support/fixtures";
34
34
  import { type HttpMock, mockFetch } from "@/test-support/http";
35
+ import {
36
+ describedMessage,
37
+ intelligenceDrawer,
38
+ MESSAGE_ID,
39
+ settle,
40
+ THREAD_ID,
41
+ } from "@/test-support/intelligence-surface";
35
42
  import { BriefPane } from "./BriefPane";
36
43
  import { FlaggedPane } from "./FlaggedPane";
37
44
 
38
45
  /** Below the rail's 1280px gate, above the reading pane's 1024px one. */
39
46
  const TWO_PANE_WIDTH = 1100;
40
47
 
41
- const THREAD_ID = "thread-1";
42
- const MESSAGE_ID = "msg-1";
43
-
44
48
  const SHOW_INTELLIGENCE = "Show intelligence sidebar";
45
49
  const HIDE_INTELLIGENCE = "Hide intelligence sidebar";
46
50
 
@@ -60,24 +64,6 @@ const row = makeThreadMessage({
60
64
  },
61
65
  });
62
66
 
63
- /** What the reading pane reads each message's own headers and body from. */
64
- const describedMessage = {
65
- messageId: MESSAGE_ID,
66
- envelope: {
67
- from: [
68
- {
69
- addressId: "addr-1",
70
- name: "Mondial Relay",
71
- email: "delivery.notice@gmail.example",
72
- },
73
- ],
74
- to: [],
75
- cc: [],
76
- bcc: [],
77
- },
78
- bodyParts: [],
79
- };
80
-
81
67
  let harness: DomHarness | undefined;
82
68
  let http: HttpMock | undefined;
83
69
 
@@ -197,15 +183,6 @@ const mount = async (pane: PaneUnderTest): Promise<DomHarness> => {
197
183
  return mounted;
198
184
  };
199
185
 
200
- const settle = async (mounted: DomHarness): Promise<void> => {
201
- await mounted.flush();
202
- await mounted.wait(20);
203
- await mounted.flush();
204
- };
205
-
206
- const drawer = (mounted: DomHarness): HTMLElement | null =>
207
- mounted.query('[role="dialog"][aria-label="Message details"]');
208
-
209
186
  describe("intelligence is reachable wherever the reading pane mounts (#817)", () => {
210
187
  for (const pane of panes) {
211
188
  it(`${pane.name} offers a live toolbar control below the rail's width`, async () => {
@@ -221,7 +198,7 @@ describe("intelligence is reachable wherever the reading pane mounts (#817)", ()
221
198
  mounted.click(toggle);
222
199
  await settle(mounted);
223
200
 
224
- assert.ok(drawer(mounted), "pressing it opened nothing");
201
+ assert.ok(intelligenceDrawer(mounted), "pressing it opened nothing");
225
202
  assert.ok(
226
203
  mounted.query(`[aria-label="${HIDE_INTELLIGENCE}"]`),
227
204
  "the toolbar still reports the surface as closed",
@@ -235,7 +212,7 @@ describe("intelligence is reachable wherever the reading pane mounts (#817)", ()
235
212
  await settle(mounted);
236
213
 
237
214
  assert.ok(
238
- drawer(mounted),
215
+ intelligenceDrawer(mounted),
239
216
  "the banner's Why? reached no intelligence surface",
240
217
  );
241
218
  });
@@ -16,7 +16,8 @@ import {
16
16
  patchThreadListQueries,
17
17
  restoreThreadListQueries,
18
18
  snapshotThreadListQueries,
19
- type ThreadListSnapshotEntry,
19
+ type ThreadMessagesData,
20
+ type ThreadMutationContext,
20
21
  threadListCacheKeys,
21
22
  } from "@/lib/thread-list-cache";
22
23
 
@@ -39,23 +40,6 @@ interface UseDeleteMessagesOptions {
39
40
  onAfterOptimisticRemove?: (messageIds: string[]) => void;
40
41
  }
41
42
 
42
- interface ThreadMessagesData {
43
- items: RemitImapThreadMessageResponse[];
44
- [key: string]: unknown;
45
- }
46
-
47
- interface SnapshotEntry<T> {
48
- queryKey: readonly unknown[];
49
- data: T;
50
- }
51
-
52
- interface DeleteContext {
53
- threadMessagesPrefix: readonly unknown[];
54
- listPrefixes: ReadonlyArray<readonly unknown[]>;
55
- previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
56
- previousThreadsList: ThreadListSnapshotEntry[];
57
- }
58
-
59
43
  /**
60
44
  * Pure helper: drop the messages in `messageIds` from a single page's items.
61
45
  *
@@ -94,7 +78,7 @@ export const useDeleteMessages = ({
94
78
 
95
79
  const { mutateAsync, isPending } = useMutation({
96
80
  ...messageBulkOperationsDeleteMessagesMutation(),
97
- onMutate: async (variables): Promise<DeleteContext> => {
81
+ onMutate: async (variables): Promise<ThreadMutationContext> => {
98
82
  const messageIds = new Set(variables.body.messageIds ?? []);
99
83
 
100
84
  const threadMessagesPrefix = threadId
@@ -1,5 +1,5 @@
1
1
  import { useAppShellLayout } from "@remit/ui";
2
- import { useCallback } from "react";
2
+ import { type RefObject, useCallback, useEffect } from "react";
3
3
  import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
4
4
  import { useMailContext } from "@/lib/mail-context";
5
5
 
@@ -10,15 +10,11 @@ import { useMailContext } from "@/lib/mail-context";
10
10
  * rail's own gate as the answer for both tiers and offered a disabled control
11
11
  * with nothing behind it between 1024 and 1280 (#817).
12
12
  */
13
- export interface IntelligenceSurface {
13
+ export interface IntelligenceSurface extends IntelligenceCommands {
14
14
  /** There is a thread to say anything about, so the toolbar's control acts. */
15
15
  canToggle: boolean;
16
16
  /** What the toolbar reports: the rail above 1280, the drawer below it. */
17
17
  isShowing: boolean;
18
- /** The toolbar's control, over whichever surface this width has. */
19
- toggle: () => void;
20
- /** The banner's "Why?" — an open, never a close. */
21
- open: () => void;
22
18
  drawerOpen: boolean;
23
19
  closeDrawer: () => void;
24
20
  /** Whether this width has room for the rail, so the rail is the surface. */
@@ -27,6 +23,19 @@ export interface IntelligenceSurface {
27
23
  openRail: () => void;
28
24
  }
29
25
 
26
+ /**
27
+ * What a mounted intelligence surface answers for. The width gate is the
28
+ * shell's own measurement, so only a component inside `AppShellSlotted` knows
29
+ * which surface this tier has; a pane provider wrapping the shell reaches it
30
+ * through a ref the mounted surface publishes into.
31
+ */
32
+ export interface IntelligenceCommands {
33
+ /** The toolbar's control, over whichever surface this width has. */
34
+ toggle: () => void;
35
+ /** The banner's "Why?" — an open, never a close. */
36
+ open: () => void;
37
+ }
38
+
30
39
  export const useIntelligenceSurface = (
31
40
  openThreadId: string | undefined,
32
41
  ): IntelligenceSurface => {
@@ -50,15 +59,40 @@ export const useIntelligenceSurface = (
50
59
  if (!railFits || intelligenceOpen) return;
51
60
  onToggleIntelligence();
52
61
  }, [railFits, intelligenceOpen, onToggleIntelligence]);
62
+ const open = useCallback(() => {
63
+ if (!railFits) {
64
+ openDrawer();
65
+ return;
66
+ }
67
+ openRail();
68
+ }, [railFits, openDrawer, openRail]);
53
69
 
54
70
  return {
55
71
  canToggle: threadId !== null,
56
72
  isShowing: threadId !== null && (railFits ? intelligenceOpen : drawerOpen),
57
73
  toggle,
58
- open: railFits ? onToggleIntelligence : openDrawer,
74
+ open,
59
75
  drawerOpen,
60
76
  closeDrawer: drawer.close,
61
77
  railFits,
62
78
  openRail,
63
79
  };
64
80
  };
81
+
82
+ /**
83
+ * Publish the mounted surface for the pane provider's keyboard handlers. Null
84
+ * while nothing is mounted to serve them, so a shortcut fired with no surface
85
+ * reaches nothing rather than writing `#intelligence` at a width that cannot
86
+ * render it (`docs/architecture/url-state.md`, R6).
87
+ */
88
+ export const usePublishIntelligenceCommands = (
89
+ ref: RefObject<IntelligenceCommands | null>,
90
+ { toggle, open }: IntelligenceCommands,
91
+ ): void => {
92
+ useEffect(() => {
93
+ ref.current = { toggle, open };
94
+ return () => {
95
+ ref.current = null;
96
+ };
97
+ }, [ref, toggle, open]);
98
+ };
@@ -16,7 +16,8 @@ import {
16
16
  patchThreadListQueries,
17
17
  restoreThreadListQueries,
18
18
  snapshotThreadListQueries,
19
- type ThreadListSnapshotEntry,
19
+ type ThreadMessagesData,
20
+ type ThreadMutationContext,
20
21
  threadListCacheKeys,
21
22
  } from "@/lib/thread-list-cache";
22
23
 
@@ -28,23 +29,6 @@ interface UseMarkAsReadOptions {
28
29
  accountId?: string;
29
30
  }
30
31
 
31
- interface ThreadMessagesData {
32
- items: RemitImapThreadMessageResponse[];
33
- [key: string]: unknown;
34
- }
35
-
36
- interface SnapshotEntry<T> {
37
- queryKey: readonly unknown[];
38
- data: T;
39
- }
40
-
41
- interface MarkAsReadContext {
42
- threadMessagesPrefix: readonly unknown[];
43
- listPrefixes: ReadonlyArray<readonly unknown[]>;
44
- previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
45
- previousThreadsList: ThreadListSnapshotEntry[];
46
- }
47
-
48
32
  /**
49
33
  * Dwell before a message the user is viewing is marked read. A glance closed
50
34
  * within this window leaves it unread (#140). Applied by the single shared
@@ -145,7 +129,7 @@ export const useMarkAsRead = ({
145
129
  const { mutate: markAsRead } = useMutation({
146
130
  ...messageBulkOperationsUpdateFlagsMutation(),
147
131
  meta: softErrorMeta,
148
- onMutate: async (variables): Promise<MarkAsReadContext> => {
132
+ onMutate: async (variables): Promise<ThreadMutationContext> => {
149
133
  const messageIds = new Set(variables.body.messageIds ?? []);
150
134
  const isRead = variables.body.isRead ?? true;
151
135
 
@@ -15,7 +15,8 @@ import {
15
15
  patchThreadListQueries,
16
16
  restoreThreadListQueries,
17
17
  snapshotThreadListQueries,
18
- type ThreadListSnapshotEntry,
18
+ type ThreadMessagesData,
19
+ type ThreadMutationContext,
19
20
  threadListCacheKeys,
20
21
  } from "@/lib/thread-list-cache";
21
22
 
@@ -31,23 +32,6 @@ interface UseMoveMessagesOptions {
31
32
  onAfterOptimisticRemove?: (messageIds: string[]) => void;
32
33
  }
33
34
 
34
- interface ThreadMessagesData {
35
- items: RemitImapThreadMessageResponse[];
36
- [key: string]: unknown;
37
- }
38
-
39
- interface SnapshotEntry<T> {
40
- queryKey: readonly unknown[];
41
- data: T;
42
- }
43
-
44
- interface MoveContext {
45
- threadMessagesPrefix: readonly unknown[];
46
- listPrefixes: ReadonlyArray<readonly unknown[]>;
47
- previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
48
- previousThreadsList: ThreadListSnapshotEntry[];
49
- }
50
-
51
35
  /**
52
36
  * Pure helper: drop the messages in `messageIds` from a single page's items.
53
37
  *
@@ -73,7 +57,7 @@ export const useMoveMessages = ({
73
57
 
74
58
  const { mutateAsync, isPending, isError } = useMutation({
75
59
  ...messageBulkOperationsMoveMessagesMutation(),
76
- onMutate: async (variables): Promise<MoveContext> => {
60
+ onMutate: async (variables): Promise<ThreadMutationContext> => {
77
61
  const messageIds = new Set(variables.body.messageIds ?? []);
78
62
 
79
63
  const threadMessagesPrefix = threadId
@@ -13,7 +13,8 @@ import {
13
13
  patchThreadListQueries,
14
14
  restoreThreadListQueries,
15
15
  snapshotThreadListQueries,
16
- type ThreadListSnapshotEntry,
16
+ type ThreadMessagesData,
17
+ type ThreadMutationContext,
17
18
  threadListCacheKeys,
18
19
  } from "@/lib/thread-list-cache";
19
20
 
@@ -48,23 +49,6 @@ export const resolveMailboxForMessage = (
48
49
  messages?.find((message) => message.messageId === messageId)?.mailboxId ??
49
50
  fallbackMailboxId;
50
51
 
51
- interface ThreadMessagesData {
52
- items: RemitImapThreadMessageResponse[];
53
- [key: string]: unknown;
54
- }
55
-
56
- interface SnapshotEntry<T> {
57
- queryKey: readonly unknown[];
58
- data: T;
59
- }
60
-
61
- interface ToggleStarContext {
62
- threadMessagesPrefix: readonly unknown[];
63
- listPrefixes: ReadonlyArray<readonly unknown[]>;
64
- previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
65
- previousThreadsList: ThreadListSnapshotEntry[];
66
- }
67
-
68
52
  export const toggleStarsInItems = (
69
53
  items: RemitImapThreadMessageResponse[],
70
54
  messageId: string,
@@ -84,7 +68,7 @@ export const useToggleStar = ({
84
68
 
85
69
  const { mutate, isPending, variables } = useMutation({
86
70
  ...messageOperationsUpdateMessageFlagsMutation(),
87
- onMutate: async (vars): Promise<ToggleStarContext> => {
71
+ onMutate: async (vars): Promise<ThreadMutationContext> => {
88
72
  const messageId = vars.path.messageId;
89
73
  const nextStarred = vars.body.isStarred ?? false;
90
74
 
@@ -11,19 +11,15 @@ import type {
11
11
  import { useMutation, useQueryClient } from "@tanstack/react-query";
12
12
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
13
13
  import { buildMutationErrorBanner } from "@/components/ui/error-banners";
14
+ import type { SnapshotEntry } from "@/lib/thread-list-cache";
14
15
 
15
16
  interface UseToggleTrustedOptions {
16
17
  messageId: string;
17
18
  }
18
19
 
19
- interface SnapshotEntry {
20
- queryKey: readonly unknown[];
21
- data: RemitImapDescribeMessageResponse;
22
- }
23
-
24
20
  interface ToggleTrustedContext {
25
21
  describePrefix: readonly unknown[];
26
- previous: SnapshotEntry[];
22
+ previous: SnapshotEntry<RemitImapDescribeMessageResponse>[];
27
23
  }
28
24
 
29
25
  export const buildTrustedFlag = (
@@ -7,9 +7,31 @@ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/type
7
7
  import type { QueryClient } from "@tanstack/react-query";
8
8
  import { patchThreadListCache, type ThreadListCache } from "./thread-cache.js";
9
9
 
10
- export interface ThreadListSnapshotEntry {
10
+ export interface SnapshotEntry<T> {
11
11
  queryKey: readonly unknown[];
12
- data: ThreadListCache;
12
+ data: T;
13
+ }
14
+
15
+ export type ThreadListSnapshotEntry = SnapshotEntry<ThreadListCache>;
16
+
17
+ /** A cached page of one thread's messages, as the thread-detail endpoint serves it. */
18
+ export interface ThreadMessagesData {
19
+ items: RemitImapThreadMessageResponse[];
20
+ [key: string]: unknown;
21
+ }
22
+
23
+ /**
24
+ * The rollback state an optimistic thread mutation captures in `onMutate`.
25
+ *
26
+ * The shape belongs to the snapshot/restore helpers below rather than to any
27
+ * one mutation, so mark-read, star, delete and move all carry the same one
28
+ * (#868).
29
+ */
30
+ export interface ThreadMutationContext {
31
+ threadMessagesPrefix: readonly unknown[];
32
+ listPrefixes: ReadonlyArray<readonly unknown[]>;
33
+ previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
34
+ previousThreadsList: ThreadListSnapshotEntry[];
13
35
  }
14
36
 
15
37
  /**
@@ -0,0 +1,42 @@
1
+ /**
2
+ * What every intelligence-surface spec needs to poke one: the message the
3
+ * reading pane reads its headers from, the settle the router and the query
4
+ * client both need, and the drawer as the DOM names it.
5
+ *
6
+ * The thread row itself is each spec's own, because the DKIM mismatch that
7
+ * raises the authenticity banner also raises the rail wherever the rail fits.
8
+ */
9
+
10
+ import type { DomHarness } from "@/test-support/dom";
11
+
12
+ export const THREAD_ID = "thread-1";
13
+ export const MESSAGE_ID = "msg-1";
14
+
15
+ /** What the reading pane reads each message's own headers and body from. */
16
+ export const describedMessage = {
17
+ messageId: MESSAGE_ID,
18
+ envelope: {
19
+ from: [
20
+ {
21
+ addressId: "addr-1",
22
+ name: "Mondial Relay",
23
+ email: "delivery.notice@gmail.example",
24
+ },
25
+ ],
26
+ to: [],
27
+ cc: [],
28
+ bcc: [],
29
+ },
30
+ bodyParts: [],
31
+ };
32
+
33
+ /** Let the router commit, the queries land, and the render that follows run. */
34
+ export const settle = async (mounted: DomHarness): Promise<void> => {
35
+ await mounted.flush();
36
+ await mounted.wait(20);
37
+ await mounted.flush();
38
+ };
39
+
40
+ /** The intelligence drawer, by the role and label it publishes. */
41
+ export const intelligenceDrawer = (mounted: DomHarness): HTMLElement | null =>
42
+ mounted.query('[role="dialog"][aria-label="Message details"]');