@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 +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/hooks/search-mirror-convergence.render.test.ts +1 -0
- package/src/hooks/search-mirror-detail.render.test.ts +1 -0
- package/src/hooks/useIntelligenceSurface.ts +9 -4
- package/src/hooks/useRailPanels.ts +128 -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/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
|
|
|
@@ -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
|
-
/**
|
|
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 } =
|
|
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
|
-
|
|
61
|
-
}, [railFits, intelligenceOpen,
|
|
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
|
|
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
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
}
|
package/src/lib/mail-context.ts
CHANGED
|
@@ -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
|
};
|
package/src/routes/mail.tsx
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
useNavigate,
|
|
11
11
|
useRouterState,
|
|
12
12
|
} from "@tanstack/react-router";
|
|
13
|
-
import { useCallback, useEffect, useMemo
|
|
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
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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:
|
|
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
|