@remit/web-client 0.0.195 → 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.195",
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