@remit/web-client 0.0.161 → 0.0.163
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/compose/ComposeForm.tsx +9 -0
- package/src/components/compose/InlineCompose.tsx +9 -9
- package/src/components/mail/BriefPane.tsx +11 -2
- package/src/components/mail/ConversationView.tsx +51 -55
- package/src/components/mail/DraftsView.tsx +2 -0
- package/src/components/mail/FlaggedPane.tsx +11 -2
- package/src/components/mail/MailboxPane.tsx +10 -28
- package/src/components/mail/MessageActionMenu.tsx +2 -1
- package/src/components/mail/MessageBody.tsx +2 -2
- package/src/components/mail/MessageCard.tsx +1 -4
- package/src/components/mail/MessageList.tsx +2 -0
- package/src/components/mail/OutboxPane.tsx +7 -1
- package/src/components/mail/SwipeableMessageRow.tsx +2 -0
- package/src/components/mail/conversation-reply-reach.render.test.ts +147 -12
- package/src/components/ui/KeyboardShortcutsModal.tsx +7 -2
- package/src/hooks/search-mirror-detail.render.test.ts +0 -1
- package/src/hooks/useSearchMirror.ts +11 -3
- package/src/lib/intelligence-pref.test.ts +77 -0
- package/src/lib/intelligence-pref.ts +33 -1
- package/src/lib/mail-context.ts +6 -6
- package/src/routes/mail.tsx +74 -30
- package/src/routing/fragment.test.ts +68 -0
- package/src/routing/fragment.ts +79 -16
- package/src/routing/index.ts +8 -2
- package/src/routing/nav-link.tsx +6 -2
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reading a message and answering it, on a desktop pane.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* the
|
|
4
|
+
* The reply leads the pane and the thread reads newest first under it, so what
|
|
5
|
+
* is being written and the turn it answers are the two things at the top. Both
|
|
6
|
+
* live in the pane's own scrolling region: the reply has no height of its own
|
|
7
|
+
* and no scroller of its own, which is what kept a second scrollbar — with the
|
|
8
|
+
* caret in the inner one — out of a single column.
|
|
9
9
|
*
|
|
10
|
-
* The
|
|
11
|
-
* beside the sender is the control that puts a message away — reclaiming the
|
|
12
|
-
* space is what a reader reaches for it to do.
|
|
10
|
+
* The chevron beside the sender is the control that puts a message away.
|
|
13
11
|
*/
|
|
14
12
|
|
|
15
13
|
import assert from "node:assert/strict";
|
|
@@ -41,6 +39,24 @@ const MESSAGE_ID = "msg-1";
|
|
|
41
39
|
|
|
42
40
|
const account = makeAccount({ accountId: ACCOUNT_ID });
|
|
43
41
|
|
|
42
|
+
const OPENING_SENT = 1_767_139_200_000;
|
|
43
|
+
const LATEST_SENT = 1_767_225_600_000;
|
|
44
|
+
|
|
45
|
+
// The turn that opened the conversation, and the one that answered it. The API
|
|
46
|
+
// hands them over oldest first (#81), which is the order this mock serves them
|
|
47
|
+
// in — the pane is what decides which end of that goes at the top.
|
|
48
|
+
const openingMessage: RemitImapThreadMessageResponse = makeThreadMessage({
|
|
49
|
+
messageId: "msg-0",
|
|
50
|
+
threadId: THREAD_ID,
|
|
51
|
+
mailboxId: MAILBOX_ID,
|
|
52
|
+
accountId: ACCOUNT_ID,
|
|
53
|
+
subject: "Lunch Thursday?",
|
|
54
|
+
fromName: "Grace Hopper",
|
|
55
|
+
fromEmail: "grace@example.com",
|
|
56
|
+
sentDate: OPENING_SENT,
|
|
57
|
+
isRead: true,
|
|
58
|
+
});
|
|
59
|
+
|
|
44
60
|
const threadMessage: RemitImapThreadMessageResponse = makeThreadMessage({
|
|
45
61
|
messageId: MESSAGE_ID,
|
|
46
62
|
threadId: THREAD_ID,
|
|
@@ -49,6 +65,7 @@ const threadMessage: RemitImapThreadMessageResponse = makeThreadMessage({
|
|
|
49
65
|
subject: "Lunch Thursday?",
|
|
50
66
|
fromName: "Ada Lovelace",
|
|
51
67
|
fromEmail: "ada@example.com",
|
|
68
|
+
sentDate: LATEST_SENT,
|
|
52
69
|
// Already read: marking one read on open is a mutation this test has no
|
|
53
70
|
// business driving.
|
|
54
71
|
isRead: true,
|
|
@@ -141,7 +158,7 @@ const mount = async (): Promise<DomHarness> => {
|
|
|
141
158
|
http = mockFetch((call) => {
|
|
142
159
|
if (call.path.endsWith("/config")) return { accounts: [account] };
|
|
143
160
|
if (call.path.endsWith(`/threads/${THREAD_ID}/messages`)) {
|
|
144
|
-
return { items: [threadMessage] };
|
|
161
|
+
return { items: [openingMessage, threadMessage] };
|
|
145
162
|
}
|
|
146
163
|
if (call.path.endsWith(`/messages/${MESSAGE_ID}`)) return describeMessage;
|
|
147
164
|
return { items: [] };
|
|
@@ -176,6 +193,35 @@ const pressReply = async (mounted: DomHarness): Promise<void> => {
|
|
|
176
193
|
const paneRegionHolding = (pane: Element, node: Node): Element | null =>
|
|
177
194
|
[...pane.children].find((child) => child.contains(node)) ?? null;
|
|
178
195
|
|
|
196
|
+
/**
|
|
197
|
+
* The utilities that make an element scroll vertically. A horizontal one — the
|
|
198
|
+
* formatting toolbar's strip of buttons — is a different thing and is not
|
|
199
|
+
* counted: it holds a row that would otherwise be cut off, not a column of
|
|
200
|
+
* content the reader moves through.
|
|
201
|
+
*/
|
|
202
|
+
const SCROLLS_VERTICALLY = /(^|\s)overflow-(auto|scroll|y-auto|y-scroll)(\s|$)/;
|
|
203
|
+
|
|
204
|
+
/** The inset ring `MessageCard` draws on the row the keyboard is sitting on. */
|
|
205
|
+
const FOCUS_RING = /ring-accent\//;
|
|
206
|
+
|
|
207
|
+
/** The turn the keyboard has, read off the ring rather than off the index. */
|
|
208
|
+
const focusedTurn = (mounted: DomHarness): string => {
|
|
209
|
+
const thread = mounted.query('[data-testid="conversation-messages"]');
|
|
210
|
+
assert.ok(thread, "the thread is on screen");
|
|
211
|
+
const focused = [...thread.children].filter((card) =>
|
|
212
|
+
[...card.querySelectorAll("*")].some((node) =>
|
|
213
|
+
FOCUS_RING.test(node.getAttribute("class") ?? ""),
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
assert.equal(focused.length, 1, "one turn carries the keyboard focus");
|
|
217
|
+
return focused[0]?.textContent ?? "";
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const verticalScrollers = (root: Element): Element[] =>
|
|
221
|
+
[root, ...root.querySelectorAll("*")].filter((node) =>
|
|
222
|
+
SCROLLS_VERTICALLY.test(node.getAttribute("class") ?? ""),
|
|
223
|
+
);
|
|
224
|
+
|
|
179
225
|
describe("answering the message that is open", () => {
|
|
180
226
|
it("puts the reply in the same scrolling region as the message", async () => {
|
|
181
227
|
const mounted = await mount();
|
|
@@ -199,6 +245,94 @@ describe("answering the message that is open", () => {
|
|
|
199
245
|
);
|
|
200
246
|
});
|
|
201
247
|
|
|
248
|
+
it("leads the pane with the reply, above the thread it answers", async () => {
|
|
249
|
+
const mounted = await mount();
|
|
250
|
+
|
|
251
|
+
await pressReply(mounted);
|
|
252
|
+
|
|
253
|
+
const compose = mounted.query('[data-testid="compose-body-area"]');
|
|
254
|
+
assert.ok(compose, "the reply opened");
|
|
255
|
+
|
|
256
|
+
const thread = mounted.query('[data-testid="conversation-messages"]');
|
|
257
|
+
assert.ok(thread, "the thread is on screen");
|
|
258
|
+
|
|
259
|
+
const following = mounted.window.Node.DOCUMENT_POSITION_FOLLOWING;
|
|
260
|
+
assert.ok(
|
|
261
|
+
(compose.compareDocumentPosition(thread) & following) === following,
|
|
262
|
+
"the reply comes before the messages, not after the last of them",
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("reads the thread newest first", async () => {
|
|
267
|
+
const mounted = await mount();
|
|
268
|
+
|
|
269
|
+
const thread = mounted.query('[data-testid="conversation-messages"]');
|
|
270
|
+
assert.ok(thread, "the thread is on screen");
|
|
271
|
+
|
|
272
|
+
const cards = [...thread.children].map((card) => card.textContent ?? "");
|
|
273
|
+
assert.equal(cards.length, 2, "both turns are on screen");
|
|
274
|
+
assert.ok(
|
|
275
|
+
cards[0]?.includes("Ada Lovelace"),
|
|
276
|
+
"the turn that answered the conversation is at the top",
|
|
277
|
+
);
|
|
278
|
+
assert.ok(
|
|
279
|
+
cards[1]?.includes("Grace Hopper"),
|
|
280
|
+
"the turn that opened it is below",
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("walks the thread with j and k in the order it is displayed in", async () => {
|
|
285
|
+
const mounted = await mount();
|
|
286
|
+
|
|
287
|
+
assert.ok(
|
|
288
|
+
focusedTurn(mounted).includes("Ada Lovelace"),
|
|
289
|
+
"the keyboard starts on the turn at the top, which is the latest one",
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const press = async (key: string): Promise<void> => {
|
|
293
|
+
mounted.dispatch(
|
|
294
|
+
mounted.window,
|
|
295
|
+
new mounted.window.KeyboardEvent("keydown", { key, bubbles: true }),
|
|
296
|
+
);
|
|
297
|
+
await mounted.flush();
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// Down the pane is back in time now that the thread reads newest first.
|
|
301
|
+
// A reorder that left the handlers alone would invert this silently, and
|
|
302
|
+
// the help overlay describes the same direction this asserts.
|
|
303
|
+
await press("j");
|
|
304
|
+
assert.ok(
|
|
305
|
+
focusedTurn(mounted).includes("Grace Hopper"),
|
|
306
|
+
"j moves to the turn below, which is the older one",
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
await press("k");
|
|
310
|
+
assert.ok(
|
|
311
|
+
focusedTurn(mounted).includes("Ada Lovelace"),
|
|
312
|
+
"k moves to the turn above, which is the newer one",
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("gives the pane one scrollbar, reply open or not", async () => {
|
|
317
|
+
const mounted = await mount();
|
|
318
|
+
|
|
319
|
+
const pane = mounted.query("article");
|
|
320
|
+
assert.ok(pane, "the conversation pane is mounted");
|
|
321
|
+
assert.equal(
|
|
322
|
+
verticalScrollers(pane).length,
|
|
323
|
+
1,
|
|
324
|
+
"reading a thread scrolls one thing",
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
await pressReply(mounted);
|
|
328
|
+
|
|
329
|
+
assert.equal(
|
|
330
|
+
verticalScrollers(pane).length,
|
|
331
|
+
1,
|
|
332
|
+
"the reply is a block of the pane, not a box with a scrollbar of its own next to the editor",
|
|
333
|
+
);
|
|
334
|
+
});
|
|
335
|
+
|
|
202
336
|
it("collapses the message from the chevron beside the sender", async () => {
|
|
203
337
|
const mounted = await mount();
|
|
204
338
|
|
|
@@ -215,9 +349,10 @@ describe("answering the message that is open", () => {
|
|
|
215
349
|
mounted.query('[aria-label="Collapse message"]') === null,
|
|
216
350
|
"the chevron collapsed the message it belongs to",
|
|
217
351
|
);
|
|
218
|
-
assert.
|
|
219
|
-
mounted.
|
|
220
|
-
|
|
352
|
+
assert.equal(
|
|
353
|
+
mounted.queryAll('[role="button"][aria-expanded="false"]').length,
|
|
354
|
+
2,
|
|
355
|
+
"the message is back to a collapsed row, beside the one that was already collapsed",
|
|
221
356
|
);
|
|
222
357
|
});
|
|
223
358
|
});
|
|
@@ -22,6 +22,8 @@ export const KeyboardShortcutsModal = ({
|
|
|
22
22
|
(event: KeyboardEvent) => {
|
|
23
23
|
if (event.key === "Escape") {
|
|
24
24
|
event.preventDefault();
|
|
25
|
+
event.stopPropagation();
|
|
26
|
+
event.stopImmediatePropagation();
|
|
25
27
|
onClose();
|
|
26
28
|
}
|
|
27
29
|
},
|
|
@@ -30,8 +32,11 @@ export const KeyboardShortcutsModal = ({
|
|
|
30
32
|
|
|
31
33
|
useEffect(() => {
|
|
32
34
|
if (!isOpen) return;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
// Capture phase, as `ConfirmDialog` does: the sheet is the topmost surface,
|
|
36
|
+
// so Escape dismisses it and nothing else. Shared with the list's own
|
|
37
|
+
// Escape, the one keystroke also closed the conversation underneath.
|
|
38
|
+
window.addEventListener("keydown", handleKeyDown, true);
|
|
39
|
+
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
|
35
40
|
}, [isOpen, handleKeyDown]);
|
|
36
41
|
|
|
37
42
|
if (!isOpen) return null;
|
|
@@ -71,16 +71,24 @@ export function useSearchMirror(target: SearchMirrorTarget): void {
|
|
|
71
71
|
...prev,
|
|
72
72
|
q: committedQuery || undefined,
|
|
73
73
|
});
|
|
74
|
+
// A query is a mode of the view the reader is already in, so the panels
|
|
75
|
+
// they have up are not ones they navigated away from.
|
|
74
76
|
if (!queryGoesActive) {
|
|
75
|
-
navigate({ to: ".", search, replace: true });
|
|
77
|
+
navigate({ to: ".", search, hash: true, replace: true });
|
|
76
78
|
return;
|
|
77
79
|
}
|
|
78
80
|
if (to === "/mail/$mailboxId") {
|
|
79
81
|
if (!mailboxId) return;
|
|
80
|
-
navigate({
|
|
82
|
+
navigate({
|
|
83
|
+
to,
|
|
84
|
+
params: { mailboxId },
|
|
85
|
+
search,
|
|
86
|
+
hash: true,
|
|
87
|
+
replace: true,
|
|
88
|
+
});
|
|
81
89
|
return;
|
|
82
90
|
}
|
|
83
|
-
navigate({ to, search, replace: true });
|
|
91
|
+
navigate({ to, search, hash: true, replace: true });
|
|
84
92
|
}, [
|
|
85
93
|
searchInput,
|
|
86
94
|
committedQuery,
|
|
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
3
|
import {
|
|
4
4
|
readIntelligencePref,
|
|
5
|
+
resolveRailOpen,
|
|
5
6
|
writeIntelligencePref,
|
|
6
7
|
} from "./intelligence-pref.js";
|
|
7
8
|
|
|
@@ -55,3 +56,79 @@ describe("intelligence-pref (#782)", () => {
|
|
|
55
56
|
assert.doesNotThrow(() => writeIntelligencePref(false));
|
|
56
57
|
});
|
|
57
58
|
});
|
|
59
|
+
|
|
60
|
+
describe("resolveRailOpen (#722)", () => {
|
|
61
|
+
const withThread = { hasThread: true, isDesktop: true };
|
|
62
|
+
|
|
63
|
+
it("opens the rail with the thread where the address says nothing", () => {
|
|
64
|
+
assert.equal(
|
|
65
|
+
resolveRailOpen({ ...withThread, panels: [], prefersOpen: true }),
|
|
66
|
+
true,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("leaves the rail down where the address says nothing and the reader collapsed it", () => {
|
|
71
|
+
assert.equal(
|
|
72
|
+
resolveRailOpen({ ...withThread, panels: [], prefersOpen: false }),
|
|
73
|
+
false,
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("never seeds the rail below the tier that has one", () => {
|
|
78
|
+
assert.equal(
|
|
79
|
+
resolveRailOpen({
|
|
80
|
+
panels: [],
|
|
81
|
+
prefersOpen: true,
|
|
82
|
+
isDesktop: false,
|
|
83
|
+
hasThread: true,
|
|
84
|
+
}),
|
|
85
|
+
false,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Otherwise the address claims a pane the shell has nothing to put in it.
|
|
90
|
+
it("never seeds the rail with no conversation open", () => {
|
|
91
|
+
assert.equal(
|
|
92
|
+
resolveRailOpen({
|
|
93
|
+
panels: [],
|
|
94
|
+
prefersOpen: true,
|
|
95
|
+
isDesktop: true,
|
|
96
|
+
hasThread: false,
|
|
97
|
+
}),
|
|
98
|
+
false,
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// A shared link naming another panel is an address that has spoken: the
|
|
103
|
+
// recipient's own preference does not get to add the rail to it.
|
|
104
|
+
it("hands a shared link's panels to the reader who opened it", () => {
|
|
105
|
+
assert.equal(
|
|
106
|
+
resolveRailOpen({
|
|
107
|
+
...withThread,
|
|
108
|
+
panels: ["shortcuts"],
|
|
109
|
+
prefersOpen: true,
|
|
110
|
+
}),
|
|
111
|
+
false,
|
|
112
|
+
);
|
|
113
|
+
assert.equal(
|
|
114
|
+
resolveRailOpen({
|
|
115
|
+
...withThread,
|
|
116
|
+
panels: ["intelligence", "shortcuts"],
|
|
117
|
+
prefersOpen: false,
|
|
118
|
+
}),
|
|
119
|
+
true,
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("opens the rail from an address on any tier", () => {
|
|
124
|
+
assert.equal(
|
|
125
|
+
resolveRailOpen({
|
|
126
|
+
panels: ["intelligence"],
|
|
127
|
+
prefersOpen: false,
|
|
128
|
+
isDesktop: false,
|
|
129
|
+
hasThread: true,
|
|
130
|
+
}),
|
|
131
|
+
true,
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Persistence for the intelligence-pane open/closed preference (#782)
|
|
2
|
+
* Persistence for the intelligence-pane open/closed preference (#782), and the
|
|
3
|
+
* precedence between it and the address (#722).
|
|
3
4
|
*
|
|
4
5
|
* The pane opens with the thread by default; a manual collapse sticks across
|
|
5
6
|
* sessions. Storage failures (private mode / quota) fall back to the default
|
|
6
7
|
* (open) rather than crashing.
|
|
7
8
|
*/
|
|
9
|
+
import type { PanelFragment } from "@/routing";
|
|
8
10
|
|
|
9
11
|
export const INTELLIGENCE_PREF_KEY = "remit:intelligence-open";
|
|
10
12
|
|
|
@@ -23,3 +25,33 @@ export function writeIntelligencePref(open: boolean): void {
|
|
|
23
25
|
// Storage unavailable — the in-memory default stands.
|
|
24
26
|
}
|
|
25
27
|
}
|
|
28
|
+
|
|
29
|
+
export interface RailVisibility {
|
|
30
|
+
/** The panels the address names. */
|
|
31
|
+
panels: readonly PanelFragment[];
|
|
32
|
+
prefersOpen: boolean;
|
|
33
|
+
/** The rail is a pane of the desktop shell; narrower tiers have a drawer. */
|
|
34
|
+
isDesktop: boolean;
|
|
35
|
+
/** The rail reads a conversation, so with none open there is nothing to be up. */
|
|
36
|
+
hasThread: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether the rail is up.
|
|
41
|
+
*
|
|
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.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveRailOpen({
|
|
50
|
+
panels,
|
|
51
|
+
prefersOpen,
|
|
52
|
+
isDesktop,
|
|
53
|
+
hasThread,
|
|
54
|
+
}: RailVisibility): boolean {
|
|
55
|
+
if (panels.length > 0) return panels.includes("intelligence");
|
|
56
|
+
return isDesktop && hasThread && prefersOpen;
|
|
57
|
+
}
|
package/src/lib/mail-context.ts
CHANGED
|
@@ -47,13 +47,14 @@ export interface MailContextValue {
|
|
|
47
47
|
onSearchClear: () => void;
|
|
48
48
|
/** Query-only clear (Esc): drops the query, keeps the thread open (#489). */
|
|
49
49
|
onSearchClearQuery: () => void;
|
|
50
|
-
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
50
|
+
/**
|
|
51
|
+
* Whether pane 4 (intelligence) is up. Resolved once by the `/mail` layout
|
|
52
|
+
* from the address and this device's preference (`resolveRailOpen`), and
|
|
53
|
+
* handed down as the answer — a consumer re-deriving it from the fragment
|
|
54
|
+
* alone would lose the preference and disagree with the shell.
|
|
55
|
+
*/
|
|
53
56
|
intelligenceOpen: boolean;
|
|
54
57
|
onToggleIntelligence: () => void;
|
|
55
|
-
/** Set the pane open/closed and persist the choice (desktop default-open). */
|
|
56
|
-
onSetIntelligenceOpen: (open: boolean) => void;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
export const MailContext = createContext<MailContextValue | null>(null);
|
|
@@ -77,7 +78,6 @@ export const useMailContext = (): MailContextValue => {
|
|
|
77
78
|
onSearchClearQuery: () => {},
|
|
78
79
|
intelligenceOpen: false,
|
|
79
80
|
onToggleIntelligence: () => {},
|
|
80
|
-
onSetIntelligenceOpen: () => {},
|
|
81
81
|
}
|
|
82
82
|
);
|
|
83
83
|
};
|
package/src/routes/mail.tsx
CHANGED
|
@@ -27,13 +27,24 @@ import { useMailboxNameIndex } from "@/hooks/useMailboxNameIndex";
|
|
|
27
27
|
import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
|
|
28
28
|
import { useStaleAccountSync } from "@/hooks/useStaleAccountSync";
|
|
29
29
|
import { hostsComposeSurface } from "@/lib/compose-routes";
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
readIntelligencePref,
|
|
32
|
+
resolveRailOpen,
|
|
33
|
+
writeIntelligencePref,
|
|
34
|
+
} from "@/lib/intelligence-pref";
|
|
31
35
|
import { MailContext } from "@/lib/mail-context";
|
|
32
36
|
import { MailFreshnessProvider } from "@/lib/mail-freshness";
|
|
33
37
|
import { mailViewKey } from "@/lib/mail-route";
|
|
34
38
|
import { buildAccountNameIndex } from "@/lib/search-token-index";
|
|
35
39
|
import { committedSearchQuery, searchInputForView } from "@/lib/search-view";
|
|
36
40
|
import { wizardEntryValue, wizardStepValue } from "@/lib/wizard-history";
|
|
41
|
+
import {
|
|
42
|
+
isOverlayPanel,
|
|
43
|
+
type OverlayPanel,
|
|
44
|
+
useOpenPanels,
|
|
45
|
+
useOpenThreadPath,
|
|
46
|
+
useSetOpenPanels,
|
|
47
|
+
} from "@/routing";
|
|
37
48
|
import "@/lib/client";
|
|
38
49
|
|
|
39
50
|
// `MailContext` / `useMailContext` live in `@/lib/mail-context` so the provider
|
|
@@ -79,19 +90,56 @@ function MailLayout() {
|
|
|
79
90
|
// tablet with no compose surface (compose lives in the reading pane, which
|
|
80
91
|
// tablet doesn't mount) — the "c" shortcut / FAB opened nothing.
|
|
81
92
|
const isSinglePane = isSinglePaneTier(tier);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
//
|
|
85
|
-
// the
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
// The panels the address carries (#722): the intelligence rail, the nav
|
|
94
|
+
// slide-over and the shortcuts sheet. The rail is a pane and the other two
|
|
95
|
+
// cover it, so the address holds a pane and an overlay at once — a sheet
|
|
96
|
+
// opening never takes the rail down — while two overlays cannot both be up.
|
|
97
|
+
const openPanels = useOpenPanels();
|
|
98
|
+
const setOpenPanels = useSetOpenPanels();
|
|
99
|
+
const openOverlay = openPanels.find(isOverlayPanel);
|
|
100
|
+
const showShortcuts = openOverlay === "shortcuts";
|
|
101
|
+
const drawerOpen = openOverlay === "nav";
|
|
102
|
+
// Pane 4 on desktop, the details drawer below it. `resolveRailOpen` is the
|
|
103
|
+
// one place the address and the stored preference meet: the address decides
|
|
104
|
+
// whenever it says anything at all, and the preference opens the rail with
|
|
105
|
+
// the thread where it is silent (#782).
|
|
106
|
+
const openThread = useOpenThreadPath();
|
|
107
|
+
// Held in state, not read back from storage each render: closing the rail
|
|
108
|
+
// where the address is silent changes nothing about the address, and the
|
|
109
|
+
// answer has to move anyway.
|
|
110
|
+
const [prefersRail, setPrefersRail] = useState(readIntelligencePref);
|
|
111
|
+
const intelligenceOpen = resolveRailOpen({
|
|
112
|
+
panels: openPanels,
|
|
113
|
+
prefersOpen: prefersRail,
|
|
114
|
+
isDesktop: tier === "desktop",
|
|
115
|
+
hasThread: openThread !== undefined,
|
|
116
|
+
});
|
|
117
|
+
// Every write states the whole set, because it is composed from what is
|
|
118
|
+
// showing rather than from what the address happens to spell: the rail open
|
|
119
|
+
// by preference alone is still open, and an overlay must not close it.
|
|
120
|
+
const showPanels = useCallback(
|
|
121
|
+
(rail: boolean, overlay: OverlayPanel | undefined) => {
|
|
122
|
+
setOpenPanels([
|
|
123
|
+
...(rail ? (["intelligence"] as const) : []),
|
|
124
|
+
...(overlay ? [overlay] : []),
|
|
125
|
+
]);
|
|
126
|
+
},
|
|
127
|
+
[setOpenPanels],
|
|
128
|
+
);
|
|
129
|
+
const handleSetIntelligenceOpen = useCallback(
|
|
130
|
+
(open: boolean) => {
|
|
131
|
+
writeIntelligencePref(open);
|
|
132
|
+
setPrefersRail(open);
|
|
133
|
+
showPanels(open, openOverlay);
|
|
134
|
+
},
|
|
135
|
+
[openOverlay, showPanels],
|
|
136
|
+
);
|
|
137
|
+
const showOverlay = useCallback(
|
|
138
|
+
(overlay: OverlayPanel | undefined) => {
|
|
139
|
+
showPanels(intelligenceOpen, overlay);
|
|
140
|
+
},
|
|
141
|
+
[intelligenceOpen, showPanels],
|
|
142
|
+
);
|
|
95
143
|
|
|
96
144
|
// Within one view, URL `q` seeds the input and is a one-directional write
|
|
97
145
|
// target: the debounced local value drives the search API and is mirrored
|
|
@@ -157,7 +205,7 @@ function MailLayout() {
|
|
|
157
205
|
bindings: [
|
|
158
206
|
{
|
|
159
207
|
key: "?",
|
|
160
|
-
handler: () =>
|
|
208
|
+
handler: () => showOverlay("shortcuts"),
|
|
161
209
|
noModifiers: false, // Allow shift+/
|
|
162
210
|
preventDefault: true,
|
|
163
211
|
},
|
|
@@ -193,12 +241,8 @@ function MailLayout() {
|
|
|
193
241
|
}, []);
|
|
194
242
|
|
|
195
243
|
const handleToggleIntelligence = useCallback(() => {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
writeIntelligencePref(next);
|
|
199
|
-
return next;
|
|
200
|
-
});
|
|
201
|
-
}, []);
|
|
244
|
+
handleSetIntelligenceOpen(!intelligenceOpen);
|
|
245
|
+
}, [handleSetIntelligenceOpen, intelligenceOpen]);
|
|
202
246
|
|
|
203
247
|
const accounts = config?.accounts ?? [];
|
|
204
248
|
const accountIds = useMemo(
|
|
@@ -218,11 +262,12 @@ function MailLayout() {
|
|
|
218
262
|
useStaleAccountSync(accounts);
|
|
219
263
|
|
|
220
264
|
const handleMailboxSelect = useCallback(() => {
|
|
221
|
-
// Auto-collapse the mobile drawer after the user picks an inbox
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
265
|
+
// Auto-collapse the mobile drawer after the user picks an inbox from the
|
|
266
|
+
// sidebar (#199). Above the slide-over the sidebar is a pane, and there is
|
|
267
|
+
// no overlay of its own to take down.
|
|
268
|
+
if (openOverlay !== "nav") return;
|
|
269
|
+
showOverlay(undefined);
|
|
270
|
+
}, [openOverlay, showOverlay]);
|
|
226
271
|
|
|
227
272
|
const mailContextValue = {
|
|
228
273
|
accounts,
|
|
@@ -239,7 +284,6 @@ function MailLayout() {
|
|
|
239
284
|
onSearchClearQuery: handleSearchClearQuery,
|
|
240
285
|
intelligenceOpen,
|
|
241
286
|
onToggleIntelligence: handleToggleIntelligence,
|
|
242
|
-
onSetIntelligenceOpen: handleSetIntelligenceOpen,
|
|
243
287
|
};
|
|
244
288
|
|
|
245
289
|
// Single nav node: the kit renders it as a pane (≥1024px) or inside its
|
|
@@ -259,8 +303,8 @@ function MailLayout() {
|
|
|
259
303
|
// top bar owns compose.
|
|
260
304
|
overlay: isSinglePane ? <ComposeFab /> : undefined,
|
|
261
305
|
navOpen: drawerOpen,
|
|
262
|
-
onOpenNav: () =>
|
|
263
|
-
onCloseNav: () =>
|
|
306
|
+
onOpenNav: () => showOverlay("nav"),
|
|
307
|
+
onCloseNav: () => showOverlay(undefined),
|
|
264
308
|
};
|
|
265
309
|
|
|
266
310
|
return (
|
|
@@ -284,7 +328,7 @@ function MailLayout() {
|
|
|
284
328
|
)}
|
|
285
329
|
<KeyboardShortcutsModal
|
|
286
330
|
isOpen={showShortcuts}
|
|
287
|
-
onClose={() =>
|
|
331
|
+
onClose={() => showOverlay(undefined)}
|
|
288
332
|
/>
|
|
289
333
|
</MailFreshnessProvider>
|
|
290
334
|
</MailContext.Provider>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
formatOpenPanels,
|
|
5
|
+
panelFragments,
|
|
6
|
+
parseOpenPanels,
|
|
7
|
+
retainOpenPanels,
|
|
8
|
+
} from "./fragment.js";
|
|
9
|
+
|
|
10
|
+
describe("parseOpenPanels", () => {
|
|
11
|
+
it("reads every panel the union names", () => {
|
|
12
|
+
for (const panel of panelFragments) {
|
|
13
|
+
assert.deepEqual(parseOpenPanels(panel), [panel]);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("reads a pane and an overlay together", () => {
|
|
18
|
+
assert.deepEqual(parseOpenPanels("intelligence,shortcuts"), [
|
|
19
|
+
"intelligence",
|
|
20
|
+
"shortcuts",
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("spells one set of panels one way", () => {
|
|
25
|
+
assert.deepEqual(parseOpenPanels("shortcuts,intelligence,shortcuts"), [
|
|
26
|
+
"intelligence",
|
|
27
|
+
"shortcuts",
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("reads an empty, unknown or mis-cased fragment as no panel", () => {
|
|
32
|
+
assert.deepEqual(parseOpenPanels(""), []);
|
|
33
|
+
assert.deepEqual(parseOpenPanels("confirm-delete"), []);
|
|
34
|
+
assert.deepEqual(parseOpenPanels("Intelligence"), []);
|
|
35
|
+
assert.deepEqual(parseOpenPanels("form:input:to"), []);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("keeps the panels it recognises out of a fragment carrying junk", () => {
|
|
39
|
+
assert.deepEqual(parseOpenPanels("filters,intelligence"), ["intelligence"]);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("formatOpenPanels", () => {
|
|
44
|
+
it("writes the address a set of panels is read back from", () => {
|
|
45
|
+
assert.equal(
|
|
46
|
+
formatOpenPanels(["shortcuts", "intelligence"]),
|
|
47
|
+
"intelligence,shortcuts",
|
|
48
|
+
);
|
|
49
|
+
assert.equal(formatOpenPanels([]), "");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("retainOpenPanels", () => {
|
|
54
|
+
it("carries a pane across a navigation", () => {
|
|
55
|
+
assert.equal(retainOpenPanels("intelligence"), "intelligence");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("leaves the overlays behind, and keeps the pane under them", () => {
|
|
59
|
+
assert.equal(retainOpenPanels("nav"), "");
|
|
60
|
+
assert.equal(retainOpenPanels("shortcuts"), "");
|
|
61
|
+
assert.equal(retainOpenPanels("intelligence,shortcuts"), "intelligence");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("drops a fragment it does not recognise rather than passing it on", () => {
|
|
65
|
+
assert.equal(retainOpenPanels(), "");
|
|
66
|
+
assert.equal(retainOpenPanels("confirm-delete"), "");
|
|
67
|
+
});
|
|
68
|
+
});
|