@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 +1 -1
- package/src/components/mail/intelligence-auto-open-pref.render.test.ts +412 -0
- package/src/components/mail/intelligence-shortcut-surface.render.test.ts +40 -10
- package/src/components/settings/DeleteFolderDialog.render.test.ts +220 -5
- package/src/components/settings/DeleteFolderDialog.tsx +90 -13
- package/src/hooks/search-mirror-convergence.render.test.ts +1 -0
- package/src/hooks/search-mirror-detail.render.test.ts +1 -0
- package/src/hooks/useDeleteFolder.ts +113 -0
- package/src/hooks/useIntelligenceSurface.ts +9 -4
- package/src/hooks/useRailPanels.ts +128 -0
- package/src/lib/delete-folder.test.ts +14 -0
- package/src/lib/delete-folder.ts +6 -0
- package/src/lib/fresh-mailbox-count.test.ts +145 -0
- package/src/lib/fresh-mailbox-count.ts +114 -0
- package/src/lib/intelligence-pref.test.ts +68 -1
- package/src/lib/intelligence-pref.ts +20 -6
- package/src/lib/mail-context.ts +8 -0
- package/src/lib/mailbox-sync-wait.ts +6 -2
- package/src/routes/mail.tsx +15 -68
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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
|
|