@remit/web-client 0.0.196 → 0.0.197

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.196",
3
+ "version": "0.0.197",
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": {
@@ -0,0 +1,412 @@
1
+ /**
2
+ * A DKIM mismatch raises the rail for the message it fired on, and for nothing
3
+ * else (#778).
4
+ *
5
+ * The auto-open reached for the reader's own toggle, so one message signed by
6
+ * the wrong domain wrote `remit:intelligence-open` to `open` and kept the rail
7
+ * up for every later thread and every later session, including for a reader who
8
+ * had collapsed the rail on purpose. The rail's two verbs are bound here through
9
+ * the real `useRailPanels`, and the layout carries a render button so a
10
+ * navigation is followed by the render the router does not force here.
11
+ */
12
+
13
+ import assert from "node:assert/strict";
14
+ import { afterEach, describe, it } from "node:test";
15
+ import { AppShellSlotted } from "@remit/ui";
16
+ import {
17
+ type AnyRouter,
18
+ createMemoryHistory,
19
+ createRootRoute,
20
+ createRoute,
21
+ createRouter,
22
+ Outlet,
23
+ RouterProvider,
24
+ } from "@tanstack/react-router";
25
+ import { createElement, type ReactNode, useState } from "react";
26
+ import { ComposeProvider } from "@/components/compose/ComposeProvider";
27
+ import { useRailPanels } from "@/hooks/useRailPanels";
28
+ import { INTELLIGENCE_PREF_KEY } from "@/lib/intelligence-pref";
29
+ import {
30
+ MailContext,
31
+ type MailContextValue,
32
+ useMailContext,
33
+ } from "@/lib/mail-context";
34
+ import { MailFreshnessProvider } from "@/lib/mail-freshness";
35
+ import { EMPTY_RESULT_FOLDER_INDEX } from "@/lib/result-folder";
36
+ import { retainOpenPanelsAtTier, useOpenThreadPath } from "@/routing";
37
+ import { createDomHarness, type DomHarness } 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
+ MESSAGE_ID,
43
+ settle,
44
+ THREAD_ID,
45
+ } from "@/test-support/intelligence-surface";
46
+ import { MailboxPane } from "./MailboxPane";
47
+
48
+ /** Wide enough for the rail, which is the surface the auto-open acts on. */
49
+ const RAIL_WIDTH = 1400;
50
+
51
+ const MAILBOX_ID = "mailbox-1";
52
+ const MESSAGE_PATH = `/mail/${MAILBOX_ID}/${THREAD_ID}/${MESSAGE_ID}`;
53
+
54
+ const NEXT_THREAD_ID = "thread-2";
55
+ const NEXT_MESSAGE_ID = "msg-2";
56
+
57
+ const SHOW_INTELLIGENCE = "Show intelligence sidebar";
58
+ const HIDE_INTELLIGENCE = "Hide intelligence sidebar";
59
+ const RENDER_AGAIN = "render again";
60
+
61
+ /** Signed by another domain: what the reading pane's auto-open fires on. */
62
+ const row = makeThreadMessage({
63
+ messageId: MESSAGE_ID,
64
+ threadId: THREAD_ID,
65
+ subject: "Your parcel could not be delivered",
66
+ fromName: "Mondial Relay",
67
+ fromEmail: "delivery.notice@gmail.example",
68
+ authenticity: {
69
+ dkimMismatch: true,
70
+ fromDomain: "mondialrelay.fr",
71
+ dkimDomain: "gmail.example",
72
+ },
73
+ });
74
+
75
+ /** The next thread the reader opens, which raised nothing of its own. */
76
+ const nextRow = makeThreadMessage({
77
+ messageId: NEXT_MESSAGE_ID,
78
+ threadId: NEXT_THREAD_ID,
79
+ subject: "Lunch on Thursday",
80
+ fromName: "Ada",
81
+ fromEmail: "ada@mondialrelay.fr",
82
+ });
83
+
84
+ /** jsdom's own storage is not on `globalThis`, which is where the pref reads. */
85
+ const installStorage = (seed?: string): Map<string, string> => {
86
+ const store = new Map<string, string>();
87
+ if (seed !== undefined) store.set(INTELLIGENCE_PREF_KEY, seed);
88
+ (globalThis as { localStorage?: Storage }).localStorage = {
89
+ getItem: (key: string) => store.get(key) ?? null,
90
+ setItem: (key: string, value: string) => {
91
+ store.set(key, value);
92
+ },
93
+ removeItem: (key: string) => {
94
+ store.delete(key);
95
+ },
96
+ clear: () => {
97
+ store.clear();
98
+ },
99
+ key: (index: number) => Array.from(store.keys())[index] ?? null,
100
+ get length() {
101
+ return store.size;
102
+ },
103
+ } as Storage;
104
+ return store;
105
+ };
106
+
107
+ let harness: DomHarness | undefined;
108
+ let http: HttpMock | undefined;
109
+
110
+ afterEach(() => {
111
+ harness?.close();
112
+ harness = undefined;
113
+ http?.restore();
114
+ http = undefined;
115
+ (globalThis as { localStorage?: Storage }).localStorage = undefined;
116
+ });
117
+
118
+ // The router reads `self` at construction; the shared jsdom globals stop at
119
+ // `window`.
120
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
121
+
122
+ /**
123
+ * The `/mail` layout's own binding, from the hook the layout uses, plus the
124
+ * button every router-driven spec here needs: this environment does not re-run
125
+ * a mounted component when the router's own state moves, so a navigation is
126
+ * followed by a press that renders the layout again
127
+ * (`hooks/search-mirror-convergence.render.test.ts`).
128
+ */
129
+ function MailLayout({ children }: { children: ReactNode }) {
130
+ const [renders, setRenders] = useState(0);
131
+ const { intelligenceOpen, toggleIntelligence, raiseIntelligence } =
132
+ useRailPanels();
133
+ const value: MailContextValue = {
134
+ accounts: [],
135
+ mailboxNameIndex: new Map(),
136
+ accountNameIndex: new Map(),
137
+ resultFolderIndex: EMPTY_RESULT_FOLDER_INDEX,
138
+ searchQuery: "",
139
+ searchInput: "",
140
+ searchViewKey: "",
141
+ onSearchChange: () => {},
142
+ onSearchClear: () => {},
143
+ onSearchClearQuery: () => {},
144
+ intelligenceOpen,
145
+ onToggleIntelligence: toggleIntelligence,
146
+ onRaiseIntelligence: raiseIntelligence,
147
+ };
148
+ return createElement(
149
+ MailContext.Provider,
150
+ { value },
151
+ createElement(
152
+ "button",
153
+ {
154
+ type: "button",
155
+ "aria-label": RENDER_AGAIN,
156
+ onClick: () => setRenders(renders + 1),
157
+ },
158
+ String(renders),
159
+ ),
160
+ createElement(MailFreshnessProvider, { accountIds: [], children }),
161
+ );
162
+ }
163
+
164
+ function Shell() {
165
+ const { intelligenceOpen } = useMailContext();
166
+ return createElement(AppShellSlotted, {
167
+ initialWidth: RAIL_WIDTH,
168
+ nav: null,
169
+ list: null,
170
+ reading: createElement(MailboxPane.Reading),
171
+ intelligence: createElement(MailboxPane.Intelligence),
172
+ intelligenceOpen,
173
+ });
174
+ }
175
+
176
+ const testRouter = (hash: string): AnyRouter => {
177
+ const rootRoute = createRootRoute({
178
+ component: () =>
179
+ createElement(
180
+ ComposeProvider,
181
+ null,
182
+ createElement(MailLayout, {
183
+ // biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
184
+ children: createElement(Outlet),
185
+ }),
186
+ ),
187
+ });
188
+ const mailRoute = createRoute({
189
+ getParentRoute: () => rootRoute,
190
+ path: "/mail",
191
+ validateSearch: (search: Record<string, unknown>) => search,
192
+ component: Outlet,
193
+ });
194
+ const mailboxRoute = createRoute({
195
+ getParentRoute: () => mailRoute,
196
+ path: "/$mailboxId",
197
+ component: () =>
198
+ createElement(MailboxPane, {
199
+ mailboxId: MAILBOX_ID,
200
+ thread: useOpenThreadPath(),
201
+ // biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
202
+ children: createElement(Outlet),
203
+ }),
204
+ });
205
+ const threadRoute = createRoute({
206
+ getParentRoute: () => mailboxRoute,
207
+ path: "$threadId",
208
+ component: Outlet,
209
+ });
210
+ const messageRoute = createRoute({
211
+ getParentRoute: () => threadRoute,
212
+ path: "$messageId",
213
+ component: () => createElement(Shell),
214
+ });
215
+ const routeTree = rootRoute.addChildren([
216
+ mailRoute.addChildren([
217
+ mailboxRoute.addChildren([threadRoute.addChildren([messageRoute])]),
218
+ ]),
219
+ ]);
220
+ return createRouter({
221
+ routeTree,
222
+ history: createMemoryHistory({
223
+ initialEntries: [`${MESSAGE_PATH}${hash}`],
224
+ }),
225
+ }) as unknown as AnyRouter;
226
+ };
227
+
228
+ const mount = async (hash = ""): Promise<[DomHarness, AnyRouter]> => {
229
+ http = mockFetch((call) => {
230
+ if (call.path.endsWith("/config")) return { accounts: [] };
231
+ if (call.path.endsWith(`/threads/${THREAD_ID}/messages`)) {
232
+ return { items: [row] };
233
+ }
234
+ if (call.path.endsWith(`/threads/${NEXT_THREAD_ID}/messages`)) {
235
+ return { items: [nextRow] };
236
+ }
237
+ if (call.path.includes("/messages/")) return describedMessage;
238
+ if (call.path.includes("/threads")) return { items: [row, nextRow] };
239
+ return { items: [] };
240
+ });
241
+
242
+ const router = testRouter(hash);
243
+ await router.load();
244
+ const mounted = createDomHarness({ viewportWidth: RAIL_WIDTH });
245
+ harness = mounted;
246
+ mounted.renderApp(createElement(RouterProvider, { router }));
247
+ // Twice: the auto-open acts from an effect the first settle runs.
248
+ await settle(mounted);
249
+ await settle(mounted);
250
+ return [mounted, router];
251
+ };
252
+
253
+ /**
254
+ * Opening the next thread, as every list opens one: the message route, with the
255
+ * fragment run through the real retain updater the rows pass as `hash`
256
+ * (`MessageList.openRow`). The rows are virtualized, so there is no row in jsdom
257
+ * to click; the press that follows is the layout's render, which the router does
258
+ * not force here.
259
+ */
260
+ const openNextThread = async (
261
+ mounted: DomHarness,
262
+ router: AnyRouter,
263
+ ): Promise<void> => {
264
+ await router.navigate({
265
+ to: "/mail/$mailboxId/$threadId/$messageId",
266
+ params: {
267
+ mailboxId: MAILBOX_ID,
268
+ threadId: NEXT_THREAD_ID,
269
+ messageId: NEXT_MESSAGE_ID,
270
+ },
271
+ hash: retainOpenPanelsAtTier(true),
272
+ });
273
+ await settle(mounted);
274
+ mounted.click(mounted.byLabel(RENDER_AGAIN));
275
+ await settle(mounted);
276
+ };
277
+
278
+ const railIsUp = (mounted: DomHarness): boolean =>
279
+ mounted.query(`[aria-label="${HIDE_INTELLIGENCE}"]`) !== null;
280
+
281
+ describe("the DKIM auto-open and the stored rail preference (#778)", () => {
282
+ it("raises the rail for the message it fired on", async () => {
283
+ const store = installStorage("closed");
284
+
285
+ const [mounted, router] = await mount();
286
+
287
+ assert.equal(railIsUp(mounted), true, "the mismatch left the rail down");
288
+ assert.equal(
289
+ store.get(INTELLIGENCE_PREF_KEY),
290
+ "closed",
291
+ "one mismatch rewrote the preference the reader chose",
292
+ );
293
+ assert.equal(
294
+ router.state.location.hash,
295
+ "",
296
+ "a raise for one message was written into the address",
297
+ );
298
+ });
299
+
300
+ it("leaves the raised rail behind when the reader opens the next thread", async () => {
301
+ const store = installStorage("closed");
302
+
303
+ const [mounted, router] = await mount();
304
+ assert.equal(railIsUp(mounted), true, "the mismatch left the rail down");
305
+
306
+ await openNextThread(mounted, router);
307
+
308
+ assert.equal(
309
+ railIsUp(mounted),
310
+ false,
311
+ "the raise travelled to a thread that never asked for it",
312
+ );
313
+ assert.equal(
314
+ store.get(INTELLIGENCE_PREF_KEY),
315
+ "closed",
316
+ "the reader's collapse did not survive the mismatch",
317
+ );
318
+ });
319
+
320
+ it("carries a rail the reader put up themselves to the next thread", async () => {
321
+ installStorage("closed");
322
+
323
+ const [mounted, router] = await mount();
324
+ // Down from the raise, then up as the reader's own answer.
325
+ mounted.click(mounted.byLabel(HIDE_INTELLIGENCE));
326
+ await settle(mounted);
327
+ mounted.click(mounted.byLabel(SHOW_INTELLIGENCE));
328
+ await settle(mounted);
329
+
330
+ await openNextThread(mounted, router);
331
+
332
+ assert.equal(railIsUp(mounted), true, "the rail came down on its own");
333
+ assert.equal(
334
+ router.state.location.hash,
335
+ "intelligence",
336
+ "the reader's own rail stayed out of the address",
337
+ );
338
+ });
339
+
340
+ it("still stores the rail the reader put away themselves", async () => {
341
+ const store = installStorage();
342
+
343
+ const [mounted] = await mount();
344
+ mounted.click(mounted.byLabel(HIDE_INTELLIGENCE));
345
+ await settle(mounted);
346
+
347
+ assert.equal(railIsUp(mounted), false, "the toggle left the rail up");
348
+ assert.equal(
349
+ store.get(INTELLIGENCE_PREF_KEY),
350
+ "closed",
351
+ "the reader's own toggle stopped storing what they chose",
352
+ );
353
+ });
354
+
355
+ it("still stores the rail the reader put up themselves", async () => {
356
+ const store = installStorage();
357
+
358
+ const [mounted] = await mount();
359
+ mounted.click(mounted.byLabel(HIDE_INTELLIGENCE));
360
+ await settle(mounted);
361
+ mounted.click(mounted.byLabel(SHOW_INTELLIGENCE));
362
+ await settle(mounted);
363
+
364
+ assert.equal(railIsUp(mounted), true, "the toggle left the rail down");
365
+ assert.equal(
366
+ store.get(INTELLIGENCE_PREF_KEY),
367
+ "open",
368
+ "the reader's own toggle stopped storing what they chose",
369
+ );
370
+ });
371
+
372
+ // A link naming the shortcuts sheet arrives with the rail down, because the
373
+ // address has spoken; the mismatch raises it anyway. Putting that rail away
374
+ // is an answer to the surfacing, not the reader revising a preference they
375
+ // still hold — and reading it as one flipped `open` to `closed` unasked.
376
+ it("puts a raised rail away without storing a collapse", async () => {
377
+ const store = installStorage("open");
378
+
379
+ const [mounted] = await mount("#shortcuts");
380
+ assert.equal(railIsUp(mounted), true, "the mismatch left the rail down");
381
+
382
+ mounted.click(mounted.byLabel(HIDE_INTELLIGENCE));
383
+ await settle(mounted);
384
+
385
+ assert.equal(railIsUp(mounted), false, "the rail would not go away");
386
+ assert.equal(
387
+ store.get(INTELLIGENCE_PREF_KEY),
388
+ "open",
389
+ "putting one message's rail away collapsed the rail everywhere",
390
+ );
391
+ });
392
+
393
+ // "Why?" asks about the message on screen. It is the same raise the mismatch
394
+ // makes, not a statement about where the reader wants the rail.
395
+ it("the banner's Why opens the rail without storing a preference", async () => {
396
+ const store = installStorage();
397
+
398
+ const [mounted] = await mount();
399
+ mounted.click(mounted.byLabel(HIDE_INTELLIGENCE));
400
+ await settle(mounted);
401
+
402
+ mounted.click(mounted.byText("button", "Why?"));
403
+ await settle(mounted);
404
+
405
+ assert.equal(railIsUp(mounted), true, "the banner's Why reached no rail");
406
+ assert.equal(
407
+ store.get(INTELLIGENCE_PREF_KEY),
408
+ "closed",
409
+ "a question about one message rewrote the stored preference",
410
+ );
411
+ });
412
+ });
@@ -25,11 +25,20 @@ import {
25
25
  Outlet,
26
26
  RouterProvider,
27
27
  } from "@tanstack/react-router";
28
- import { createElement, type ReactNode, useCallback } from "react";
28
+ import { createElement, type ReactNode, useCallback, useState } from "react";
29
29
  import { ComposeProvider } from "@/components/compose/ComposeProvider";
30
- import { MailContext, type MailContextValue } from "@/lib/mail-context";
30
+ import {
31
+ MailContext,
32
+ type MailContextValue,
33
+ useMailContext,
34
+ } from "@/lib/mail-context";
31
35
  import { EMPTY_RESULT_FOLDER_INDEX } from "@/lib/result-folder";
32
- import { useOpenPanels, useOpenThreadPath, useSetOpenPanels } from "@/routing";
36
+ import {
37
+ isOverlayPanel,
38
+ useOpenPanels,
39
+ useOpenThreadPath,
40
+ useSetOpenPanels,
41
+ } from "@/routing";
33
42
  import {
34
43
  createDomHarness,
35
44
  type DomHarness,
@@ -91,10 +100,22 @@ afterEach(() => {
91
100
  function MailLayout({ children }: { children: ReactNode }) {
92
101
  const openPanels = useOpenPanels();
93
102
  const setOpenPanels = useSetOpenPanels();
94
- const intelligenceOpen = openPanels.includes("intelligence");
103
+ // A raise is held in memory against the open message, the way the layout
104
+ // holds it; the address carries the reader's own answer alone.
105
+ const [raised, setRaised] = useState(false);
106
+ const intelligenceOpen = openPanels.includes("intelligence") || raised;
107
+ // The overlays travel through the write, the way the layout composes the
108
+ // whole set: a sheet up over the rail is not closed by the rail moving.
109
+ const overlays = openPanels.filter(isOverlayPanel);
95
110
  const onToggleIntelligence = useCallback(() => {
96
- setOpenPanels(intelligenceOpen ? [] : ["intelligence"]);
97
- }, [intelligenceOpen, setOpenPanels]);
111
+ setRaised(false);
112
+ setOpenPanels(
113
+ intelligenceOpen ? overlays : ["intelligence" as const, ...overlays],
114
+ );
115
+ }, [intelligenceOpen, overlays, setOpenPanels]);
116
+ const onRaiseIntelligence = useCallback(() => {
117
+ setRaised(true);
118
+ }, []);
98
119
 
99
120
  const value: MailContextValue = {
100
121
  accounts: [],
@@ -109,6 +130,7 @@ function MailLayout({ children }: { children: ReactNode }) {
109
130
  onSearchClearQuery: () => {},
110
131
  intelligenceOpen,
111
132
  onToggleIntelligence,
133
+ onRaiseIntelligence,
112
134
  };
113
135
  return createElement(MailContext.Provider, { value }, children);
114
136
  }
@@ -116,10 +138,10 @@ function MailLayout({ children }: { children: ReactNode }) {
116
138
  /**
117
139
  * The shell as `MailShell` mounts it: one pane carrying the phone view below
118
140
  * the reading boundary, the slots above it, and the rail's visibility taken
119
- * from the address the same way the layout takes it.
141
+ * from the layout's own answer, which is the shell's only source for it.
120
142
  */
121
143
  function Shell({ width }: { width: number }) {
122
- const intelligenceOpen = useOpenPanels().includes("intelligence");
144
+ const { intelligenceOpen } = useMailContext();
123
145
  if (width < 1024) {
124
146
  return createElement(AppShellSlotted, {
125
147
  initialWidth: width,
@@ -296,17 +318,25 @@ describe("the intelligence keys reach the surface the width has (#840)", () => {
296
318
  );
297
319
  });
298
320
 
299
- it("writes the fragment for block sender where the rail is the surface", async () => {
321
+ // Block sender asks about the message on screen, so it raises the rail for
322
+ // that message and leaves the address alone (#778) — the surface it must
323
+ // reach is still the rail, which is what this width has.
324
+ it("raises the rail for block sender where the rail is the surface", async () => {
300
325
  const [mounted, router] = await mountAt(RAIL_WIDTH);
301
326
 
302
327
  await press(mounted, "b");
303
328
 
304
- assert.equal(router.state.location.hash, "intelligence");
329
+ assert.ok(rail(mounted), "block sender reached no rail");
305
330
  assert.equal(
306
331
  intelligenceDrawer(mounted),
307
332
  null,
308
333
  "the drawer came up where the rail is the surface",
309
334
  );
335
+ assert.equal(
336
+ router.state.location.hash,
337
+ "",
338
+ "a raise for one message was written into the address",
339
+ );
310
340
  });
311
341
  });
312
342
 
@@ -63,6 +63,7 @@ const mailContext = (input: string, committed: string): MailContextValue => ({
63
63
  onSearchClearQuery: () => {},
64
64
  intelligenceOpen: false,
65
65
  onToggleIntelligence: () => {},
66
+ onRaiseIntelligence: () => {},
66
67
  });
67
68
 
68
69
  /**
@@ -65,6 +65,7 @@ const mailContext = (
65
65
  onSearchClearQuery: () => {},
66
66
  intelligenceOpen: false,
67
67
  onToggleIntelligence: () => {},
68
+ onRaiseIntelligence: () => {},
68
69
  });
69
70
 
70
71
  type ListPath = "/mail/brief" | "/mail/flagged";
@@ -19,7 +19,11 @@ export interface IntelligenceSurface extends IntelligenceCommands {
19
19
  closeDrawer: () => void;
20
20
  /** Whether this width has room for the rail, so the rail is the surface. */
21
21
  railFits: boolean;
22
- /** Raise the rail if it fits and is down. The DKIM auto-open's way in. */
22
+ /**
23
+ * Raise the rail if it fits and is down, for this thread only. The DKIM
24
+ * auto-open's way in, so it never stores a preference the reader never gave
25
+ * (#778).
26
+ */
23
27
  openRail: () => void;
24
28
  }
25
29
 
@@ -39,7 +43,8 @@ export interface IntelligenceCommands {
39
43
  export const useIntelligenceSurface = (
40
44
  openThreadId: string | undefined,
41
45
  ): IntelligenceSurface => {
42
- const { intelligenceOpen, onToggleIntelligence } = useMailContext();
46
+ const { intelligenceOpen, onToggleIntelligence, onRaiseIntelligence } =
47
+ useMailContext();
43
48
  const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
44
49
  const threadId = openThreadId ?? null;
45
50
  const drawer = useIntelligenceDrawer(threadId);
@@ -57,8 +62,8 @@ export const useIntelligenceSurface = (
57
62
  }, [railFits, onToggleIntelligence, toggleDrawer]);
58
63
  const openRail = useCallback(() => {
59
64
  if (!railFits || intelligenceOpen) return;
60
- onToggleIntelligence();
61
- }, [railFits, intelligenceOpen, onToggleIntelligence]);
65
+ onRaiseIntelligence();
66
+ }, [railFits, intelligenceOpen, onRaiseIntelligence]);
62
67
  const open = useCallback(() => {
63
68
  if (!railFits) {
64
69
  openDrawer();
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The binding between the address, this device's stored preference and what the
3
+ * `/mail` shell has up: pane 4 and the one overlay that may cover it.
4
+ *
5
+ * The rail goes up two ways and they are not the same fact. The reader's own
6
+ * control states where they want the rail from now on, so it writes both the
7
+ * preference and the address. A raise — the DKIM auto-open, the authenticity
8
+ * banner's "Why?" — surfaces the rail for the message in front of them and is
9
+ * held here, in memory, against that message: it never writes the preference,
10
+ * and opening anything else ends it. One message signed by the wrong domain used
11
+ * to mean the rail was up for every later thread and every later session (#778).
12
+ *
13
+ * The raise stays out of the address because its whole copy is computed from the
14
+ * open message — reload the URL and the mismatch raises it again on its own, so
15
+ * putting it in the fragment would be a second owner of a fact the message
16
+ * already holds (`docs/architecture/url-state.md`, R6).
17
+ */
18
+ import { useCallback, useState } from "react";
19
+ import { useLayoutTier } from "@/hooks/useLayoutTier";
20
+ import {
21
+ readIntelligencePref,
22
+ resolveRailOpen,
23
+ writeIntelligencePref,
24
+ } from "@/lib/intelligence-pref";
25
+ import {
26
+ isOverlayPanel,
27
+ type OverlayPanel,
28
+ useOpenPanels,
29
+ useOpenThreadPath,
30
+ useSetOpenPanels,
31
+ } from "@/routing";
32
+
33
+ export interface RailPanels {
34
+ /** The overlay the address holds: the nav slide-over or the shortcuts sheet. */
35
+ openOverlay: OverlayPanel | undefined;
36
+ /** Whether pane 4 is up, by the reader's standing answer or a raise. */
37
+ intelligenceOpen: boolean;
38
+ showOverlay: (overlay: OverlayPanel | undefined) => void;
39
+ /**
40
+ * The reader's own control, which is what a stored preference is made of —
41
+ * except over a rail only a raise has up, where it ends the raise and stores
42
+ * nothing.
43
+ */
44
+ toggleIntelligence: () => void;
45
+ /** Puts the rail up for the message in front of the reader, and no further. */
46
+ raiseIntelligence: () => void;
47
+ }
48
+
49
+ export const useRailPanels = (): RailPanels => {
50
+ const tier = useLayoutTier();
51
+ // The panels the address carries (#722): the intelligence rail, the nav
52
+ // slide-over and the shortcuts sheet. The rail is a pane and the other two
53
+ // cover it, so the address holds a pane and an overlay at once — a sheet
54
+ // opening never takes the rail down — while two overlays cannot both be up.
55
+ const openPanels = useOpenPanels();
56
+ const setOpenPanels = useSetOpenPanels();
57
+ const openOverlay = openPanels.find(isOverlayPanel);
58
+ // Pane 4 on desktop, the details drawer below it. `resolveRailOpen` is the
59
+ // one place the address and the stored preference meet: the address decides
60
+ // whenever it says anything at all, and the preference opens the rail with
61
+ // the thread where it is silent (#782).
62
+ const openThread = useOpenThreadPath();
63
+ // Held in state, not read back from storage each render: closing the rail
64
+ // where the address is silent changes nothing about the address, and the
65
+ // answer has to move anyway.
66
+ const [prefersRail, setPrefersRail] = useState(readIntelligencePref);
67
+ // The message a raise belongs to. Naming it by address is what ends the raise
68
+ // when the reader opens anything else, without a teardown that has to run.
69
+ const openMessage = openThread
70
+ ? `${openThread.threadId}/${openThread.messageId ?? ""}`
71
+ : null;
72
+ const [raisedFor, setRaisedFor] = useState<string | null>(null);
73
+ const visibility = {
74
+ panels: openPanels,
75
+ prefersOpen: prefersRail,
76
+ isDesktop: tier === "desktop",
77
+ hasThread: openThread !== undefined,
78
+ openMessage,
79
+ };
80
+ const intelligenceOpen = resolveRailOpen({ ...visibility, raisedFor });
81
+ // What the reader themselves have the rail at, which is what an address write
82
+ // states. A raise is left out of it on purpose: a sheet opening over a
83
+ // surfaced rail must not write that rail into the address as a choice.
84
+ const chosenOpen = resolveRailOpen({ ...visibility, raisedFor: null });
85
+ // Every write states the whole set, because it is composed from what is
86
+ // showing rather than from what the address happens to spell: the rail open
87
+ // by preference alone is still open, and an overlay must not close it.
88
+ const showPanels = useCallback(
89
+ (rail: boolean, overlay: OverlayPanel | undefined) => {
90
+ setOpenPanels([
91
+ ...(rail ? (["intelligence"] as const) : []),
92
+ ...(overlay ? [overlay] : []),
93
+ ]);
94
+ },
95
+ [setOpenPanels],
96
+ );
97
+ const showOverlay = useCallback(
98
+ (overlay: OverlayPanel | undefined) => {
99
+ showPanels(chosenOpen, overlay);
100
+ },
101
+ [chosenOpen, showPanels],
102
+ );
103
+ const toggleIntelligence = useCallback(() => {
104
+ // Putting away a rail that only a raise has up answers the surfacing, not
105
+ // the question of where the reader wants the rail — the answer they gave
106
+ // last stands, and the address never carried this rail to rewrite.
107
+ if (intelligenceOpen && !chosenOpen) {
108
+ setRaisedFor(null);
109
+ return;
110
+ }
111
+ const open = !intelligenceOpen;
112
+ writeIntelligencePref(open);
113
+ setPrefersRail(open);
114
+ setRaisedFor(null);
115
+ showPanels(open, openOverlay);
116
+ }, [chosenOpen, intelligenceOpen, openOverlay, showPanels]);
117
+ const raiseIntelligence = useCallback(() => {
118
+ setRaisedFor(openMessage);
119
+ }, [openMessage]);
120
+
121
+ return {
122
+ openOverlay,
123
+ intelligenceOpen,
124
+ showOverlay,
125
+ toggleIntelligence,
126
+ raiseIntelligence,
127
+ };
128
+ };
@@ -58,7 +58,10 @@ describe("intelligence-pref (#782)", () => {
58
58
  });
59
59
 
60
60
  describe("resolveRailOpen (#722)", () => {
61
- const withThread = { hasThread: true, isDesktop: true };
61
+ const OPEN_MESSAGE = "thread-1/msg-1";
62
+ // No raise live, and a message on screen for one to be measured against.
63
+ const unraised = { raisedFor: null, openMessage: OPEN_MESSAGE };
64
+ const withThread = { hasThread: true, isDesktop: true, ...unraised };
62
65
 
63
66
  it("opens the rail with the thread where the address says nothing", () => {
64
67
  assert.equal(
@@ -81,6 +84,7 @@ describe("resolveRailOpen (#722)", () => {
81
84
  prefersOpen: true,
82
85
  isDesktop: false,
83
86
  hasThread: true,
87
+ ...unraised,
84
88
  }),
85
89
  false,
86
90
  );
@@ -94,6 +98,7 @@ describe("resolveRailOpen (#722)", () => {
94
98
  prefersOpen: true,
95
99
  isDesktop: true,
96
100
  hasThread: false,
101
+ ...unraised,
97
102
  }),
98
103
  false,
99
104
  );
@@ -127,6 +132,68 @@ describe("resolveRailOpen (#722)", () => {
127
132
  prefersOpen: false,
128
133
  isDesktop: false,
129
134
  hasThread: true,
135
+ ...unraised,
136
+ }),
137
+ true,
138
+ );
139
+ });
140
+ });
141
+
142
+ /**
143
+ * A DKIM mismatch, or the banner's "Why?", puts the rail up for the message the
144
+ * reader is looking at. It is measured against that message rather than stored,
145
+ * so it reaches neither the address nor the preference, and the next thread the
146
+ * reader opens is not carrying it (#778).
147
+ */
148
+ describe("a raised rail belongs to its message (#778)", () => {
149
+ const OPEN_MESSAGE = "thread-1/msg-1";
150
+ const collapsed = {
151
+ hasThread: true,
152
+ isDesktop: true,
153
+ panels: [] as const,
154
+ prefersOpen: false,
155
+ };
156
+
157
+ it("puts the rail up over a collapse for the message it was raised for", () => {
158
+ assert.equal(
159
+ resolveRailOpen({
160
+ ...collapsed,
161
+ raisedFor: OPEN_MESSAGE,
162
+ openMessage: OPEN_MESSAGE,
163
+ }),
164
+ true,
165
+ );
166
+ });
167
+
168
+ it("ends with the message, so the next thread opens as the reader left it", () => {
169
+ assert.equal(
170
+ resolveRailOpen({
171
+ ...collapsed,
172
+ raisedFor: OPEN_MESSAGE,
173
+ openMessage: "thread-2/msg-2",
174
+ }),
175
+ false,
176
+ );
177
+ assert.equal(
178
+ resolveRailOpen({
179
+ ...collapsed,
180
+ hasThread: false,
181
+ raisedFor: OPEN_MESSAGE,
182
+ openMessage: null,
183
+ }),
184
+ false,
185
+ );
186
+ });
187
+
188
+ // The reader is being shown something about the message in front of them, so
189
+ // a link that named other panels does not get to suppress it.
190
+ it("surfaces over an address that named another panel", () => {
191
+ assert.equal(
192
+ resolveRailOpen({
193
+ ...collapsed,
194
+ panels: ["shortcuts"],
195
+ raisedFor: OPEN_MESSAGE,
196
+ openMessage: OPEN_MESSAGE,
130
197
  }),
131
198
  true,
132
199
  );
@@ -34,24 +34,38 @@ export interface RailVisibility {
34
34
  isDesktop: boolean;
35
35
  /** The rail reads a conversation, so with none open there is nothing to be up. */
36
36
  hasThread: boolean;
37
+ /** The message a transient raise was made for, if one is live. */
38
+ raisedFor: string | null;
39
+ /** The message on screen, which is what a raise is measured against. */
40
+ openMessage: string | null;
37
41
  }
38
42
 
39
43
  /**
40
44
  * Whether the rail is up.
41
45
  *
42
- * An address that names any panel is the only owner of what is open, so a
43
- * shared link showing the shortcuts sheet is not overwritten by the recipient's
44
- * own preference. The preference speaks only where the address is silent, and
45
- * only with a conversation open on the tier that has a rail — it opens with the
46
- * thread there (#782), while a phone would get a full-screen drawer over a
47
- * message nobody asked to cover.
46
+ * A raise answers first, and only for the message it was made for: a DKIM
47
+ * mismatch or the banner's "Why?" surfaces the rail over a collapse, because
48
+ * the reader is being shown something about what is in front of them — and
49
+ * opening anything else ends it, so one mismatched signature is not the chrome
50
+ * of the whole session (#778). It is measured rather than stored, which is why
51
+ * it never reaches the address or the preference.
52
+ *
53
+ * Otherwise an address that names any panel is the only owner of what is open,
54
+ * so a shared link showing the shortcuts sheet is not overwritten by the
55
+ * recipient's own preference. The preference speaks only where the address is
56
+ * silent, and only with a conversation open on the tier that has a rail — it
57
+ * opens with the thread there (#782), while a phone would get a full-screen
58
+ * drawer over a message nobody asked to cover.
48
59
  */
49
60
  export function resolveRailOpen({
50
61
  panels,
51
62
  prefersOpen,
52
63
  isDesktop,
53
64
  hasThread,
65
+ raisedFor,
66
+ openMessage,
54
67
  }: RailVisibility): boolean {
68
+ if (raisedFor !== null && raisedFor === openMessage) return true;
55
69
  if (panels.length > 0) return panels.includes("intelligence");
56
70
  return isDesktop && hasThread && prefersOpen;
57
71
  }
@@ -54,7 +54,14 @@ export interface MailContextValue {
54
54
  * alone would lose the preference and disagree with the shell.
55
55
  */
56
56
  intelligenceOpen: boolean;
57
+ /** The reader's own control over pane 4, which stores what they chose. */
57
58
  onToggleIntelligence: () => void;
59
+ /**
60
+ * Puts pane 4 up for the thread in hand without touching the stored
61
+ * preference: a DKIM mismatch surfacing the rail is about this message, not
62
+ * about where the reader wants the rail from now on (#778).
63
+ */
64
+ onRaiseIntelligence: () => void;
58
65
  }
59
66
 
60
67
  export const MailContext = createContext<MailContextValue | null>(null);
@@ -78,6 +85,7 @@ export const useMailContext = (): MailContextValue => {
78
85
  onSearchClearQuery: () => {},
79
86
  intelligenceOpen: false,
80
87
  onToggleIntelligence: () => {},
88
+ onRaiseIntelligence: () => {},
81
89
  }
82
90
  );
83
91
  };
@@ -10,7 +10,7 @@ import {
10
10
  useNavigate,
11
11
  useRouterState,
12
12
  } from "@tanstack/react-router";
13
- import { useCallback, useEffect, useMemo, useState } from "react";
13
+ import { useCallback, useEffect, useMemo } from "react";
14
14
  import { z } from "zod";
15
15
  import { ComposeFab } from "@/components/layout/ComposeFab";
16
16
  import { MailShellProvider } from "@/components/layout/MailShell";
@@ -21,28 +21,16 @@ import { KeyboardShortcutsModal } from "@/components/ui/KeyboardShortcutsModal";
21
21
  import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
22
22
  import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
23
23
  import { useMailboxNameIndex } from "@/hooks/useMailboxNameIndex";
24
+ import { useRailPanels } from "@/hooks/useRailPanels";
24
25
  import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
25
26
  import { useSearchField } from "@/hooks/useSearchField";
26
27
  import { useStaleAccountSync } from "@/hooks/useStaleAccountSync";
27
- import {
28
- readIntelligencePref,
29
- resolveRailOpen,
30
- writeIntelligencePref,
31
- } from "@/lib/intelligence-pref";
32
28
  import { MailContext } from "@/lib/mail-context";
33
29
  import { MailFreshnessProvider } from "@/lib/mail-freshness";
34
30
  import { mailListRoute } from "@/lib/mail-route";
35
31
  import { buildAccountNameIndex } from "@/lib/search-token-index";
36
32
  import { wizardEntryValue, wizardStepValue } from "@/lib/wizard-history";
37
- import {
38
- isOverlayPanel,
39
- type OverlayPanel,
40
- useIsComposing,
41
- useOpenCompose,
42
- useOpenPanels,
43
- useOpenThreadPath,
44
- useSetOpenPanels,
45
- } from "@/routing";
33
+ import { useIsComposing, useOpenCompose } from "@/routing";
46
34
  import "@/lib/client";
47
35
 
48
36
  // `MailContext` / `useMailContext` live in `@/lib/mail-context` so the provider
@@ -87,56 +75,18 @@ function MailLayout() {
87
75
  // tablet with no compose surface (compose lives in the reading pane, which
88
76
  // tablet doesn't mount) — the "c" shortcut / FAB opened nothing.
89
77
  const isSinglePane = isSinglePaneTier(tier);
90
- // The panels the address carries (#722): the intelligence rail, the nav
91
- // slide-over and the shortcuts sheet. The rail is a pane and the other two
92
- // cover it, so the address holds a pane and an overlay at once — a sheet
93
- // opening never takes the rail down — while two overlays cannot both be up.
94
- const openPanels = useOpenPanels();
95
- const setOpenPanels = useSetOpenPanels();
96
- const openOverlay = openPanels.find(isOverlayPanel);
78
+ // What the address, the stored preference and the two rail verbs come to
79
+ // (`hooks/useRailPanels`): a raise surfaces the rail for the thread in hand,
80
+ // the reader's own toggle is the one that stores where they want it (#778).
81
+ const {
82
+ openOverlay,
83
+ intelligenceOpen,
84
+ showOverlay,
85
+ toggleIntelligence,
86
+ raiseIntelligence,
87
+ } = useRailPanels();
97
88
  const showShortcuts = openOverlay === "shortcuts";
98
89
  const drawerOpen = openOverlay === "nav";
99
- // Pane 4 on desktop, the details drawer below it. `resolveRailOpen` is the
100
- // one place the address and the stored preference meet: the address decides
101
- // whenever it says anything at all, and the preference opens the rail with
102
- // the thread where it is silent (#782).
103
- const openThread = useOpenThreadPath();
104
- // Held in state, not read back from storage each render: closing the rail
105
- // where the address is silent changes nothing about the address, and the
106
- // answer has to move anyway.
107
- const [prefersRail, setPrefersRail] = useState(readIntelligencePref);
108
- const intelligenceOpen = resolveRailOpen({
109
- panels: openPanels,
110
- prefersOpen: prefersRail,
111
- isDesktop: tier === "desktop",
112
- hasThread: openThread !== undefined,
113
- });
114
- // Every write states the whole set, because it is composed from what is
115
- // showing rather than from what the address happens to spell: the rail open
116
- // by preference alone is still open, and an overlay must not close it.
117
- const showPanels = useCallback(
118
- (rail: boolean, overlay: OverlayPanel | undefined) => {
119
- setOpenPanels([
120
- ...(rail ? (["intelligence"] as const) : []),
121
- ...(overlay ? [overlay] : []),
122
- ]);
123
- },
124
- [setOpenPanels],
125
- );
126
- const handleSetIntelligenceOpen = useCallback(
127
- (open: boolean) => {
128
- writeIntelligencePref(open);
129
- setPrefersRail(open);
130
- showPanels(open, openOverlay);
131
- },
132
- [openOverlay, showPanels],
133
- );
134
- const showOverlay = useCallback(
135
- (overlay: OverlayPanel | undefined) => {
136
- showPanels(intelligenceOpen, overlay);
137
- },
138
- [intelligenceOpen, showPanels],
139
- );
140
90
 
141
91
  // The one search field and the query it commits (`useSearchField`): seeded
142
92
  // from the URL, mirrored back by each list route's own `useSearchMirror`,
@@ -210,10 +160,6 @@ function MailLayout() {
210
160
  setSearchInput("");
211
161
  }, [setSearchInput]);
212
162
 
213
- const handleToggleIntelligence = useCallback(() => {
214
- handleSetIntelligenceOpen(!intelligenceOpen);
215
- }, [handleSetIntelligenceOpen, intelligenceOpen]);
216
-
217
163
  const accounts = config?.accounts ?? [];
218
164
  const accountIds = useMemo(
219
165
  () => accounts.map((account) => account.accountId),
@@ -253,7 +199,8 @@ function MailLayout() {
253
199
  onSearchClear: handleSearchClear,
254
200
  onSearchClearQuery: handleSearchClearQuery,
255
201
  intelligenceOpen,
256
- onToggleIntelligence: handleToggleIntelligence,
202
+ onToggleIntelligence: toggleIntelligence,
203
+ onRaiseIntelligence: raiseIntelligence,
257
204
  };
258
205
 
259
206
  // Single nav node: the kit renders it as a pane (≥1024px) or inside its