@remit/web-client 0.0.14 → 0.0.16
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/BriefPane.tsx +34 -5
- package/src/components/mail/FlaggedPane.tsx +30 -5
- package/src/components/mail/MailboxPane.tsx +62 -11
- package/src/components/mail/MessageList.tsx +196 -78
- package/src/components/mail/MessageListItem.tsx +60 -13
- package/src/components/mail/MessageToolbar.render.test.ts +29 -1
- package/src/components/mail/MessageToolbar.tsx +20 -28
- package/src/components/mail/SwipeableMessageRow.tsx +8 -0
- package/src/components/ui/ConfirmDialog.tsx +9 -7
- package/src/hooks/useTriageKeyboard.ts +13 -8
- package/src/lib/keymap-dispatch.test.ts +78 -1
- package/src/lib/keymap-dispatch.ts +64 -7
- package/src/lib/keymap.ts +23 -0
- package/src/lib/list-focus.test.ts +25 -0
- package/src/lib/list-focus.ts +24 -0
- package/src/routes/mail.tsx +8 -2
|
@@ -66,13 +66,15 @@ export const ConfirmDialog = ({
|
|
|
66
66
|
if (!isOpen) return null;
|
|
67
67
|
|
|
68
68
|
return (
|
|
69
|
-
<div
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
69
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
70
|
+
{/* Backdrop. It carries the click-to-dismiss and the aria-hidden: the
|
|
71
|
+
dialog itself must stay in the accessibility tree, and an
|
|
72
|
+
aria-hidden ancestor would take it out. */}
|
|
73
|
+
<div
|
|
74
|
+
className="absolute inset-0 bg-canvas/80 backdrop-blur-sm"
|
|
75
|
+
aria-hidden="true"
|
|
76
|
+
onClick={onCancel}
|
|
77
|
+
/>
|
|
76
78
|
|
|
77
79
|
{/* Dialog */}
|
|
78
80
|
<div
|
|
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
|
|
|
2
2
|
import type { TriageAction } from "@/lib/keymap";
|
|
3
3
|
import {
|
|
4
4
|
dispatchKey,
|
|
5
|
+
isControlTarget,
|
|
5
6
|
isEditableTarget,
|
|
6
7
|
type SequencePrefix,
|
|
7
8
|
} from "@/lib/keymap-dispatch";
|
|
@@ -27,14 +28,17 @@ interface UseTriageKeyboardOptions {
|
|
|
27
28
|
* even Esc is left to the focused field's own handler) and carrying the `g …`
|
|
28
29
|
* go-to sequence prefix across keystrokes with a timeout.
|
|
29
30
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
31
|
+
* List navigation and selection route through here and nowhere else: the
|
|
32
|
+
* message list publishes its commands upward (see `MessageListCommands`) and
|
|
33
|
+
* the route wires them into the handler table, so `@/lib/keymap` is the source
|
|
34
|
+
* of truth for both the displayed bindings and the routed ones. The list used
|
|
35
|
+
* to run a second window listener claiming the same keys, which is what made
|
|
36
|
+
* Enter unusable on every focused button in the app (#43).
|
|
37
|
+
*
|
|
38
|
+
* Other window-level keydown listeners still exist for keys this layer does not
|
|
39
|
+
* own — `?` at the mail layout, `/` in SearchBar, Esc in the compose and
|
|
40
|
+
* conversation views. They bind disjoint keys; only the list's competing
|
|
41
|
+
* listener was removed.
|
|
38
42
|
*
|
|
39
43
|
* Per-action targeting (focused row vs selection) and the actual mutations live
|
|
40
44
|
* in the handlers the caller passes in — this hook only dispatches.
|
|
@@ -70,6 +74,7 @@ export function useTriageKeyboard({
|
|
|
70
74
|
ctrlKey: event.ctrlKey,
|
|
71
75
|
altKey: event.altKey,
|
|
72
76
|
inEditable: isEditableTarget(event.target),
|
|
77
|
+
onControl: isControlTarget(event.target),
|
|
73
78
|
},
|
|
74
79
|
prefixRef.current,
|
|
75
80
|
);
|
|
@@ -12,6 +12,7 @@ const stroke = (partial: Partial<KeyStroke> & { key: string }): KeyStroke => ({
|
|
|
12
12
|
ctrlKey: false,
|
|
13
13
|
altKey: false,
|
|
14
14
|
inEditable: false,
|
|
15
|
+
onControl: false,
|
|
15
16
|
...partial,
|
|
16
17
|
});
|
|
17
18
|
|
|
@@ -61,6 +62,82 @@ describe("dispatchKey — plain bindings", () => {
|
|
|
61
62
|
});
|
|
62
63
|
});
|
|
63
64
|
|
|
65
|
+
describe("dispatchKey — list navigation", () => {
|
|
66
|
+
const cases: Array<[string, string]> = [
|
|
67
|
+
["ArrowDown", "focusNext"],
|
|
68
|
+
["ArrowUp", "focusPrevious"],
|
|
69
|
+
["Home", "focusFirst"],
|
|
70
|
+
["End", "focusLast"],
|
|
71
|
+
[" ", "toggleSelect"],
|
|
72
|
+
["Delete", "delete"],
|
|
73
|
+
["Backspace", "delete"],
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
for (const [key, action] of cases) {
|
|
77
|
+
test(`'${key}' → ${action}`, () => {
|
|
78
|
+
const result = run({ key });
|
|
79
|
+
assert.strictEqual(result.action, action);
|
|
80
|
+
assert.strictEqual(result.preventDefault, true);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
test("Shift+Arrow extends the selection instead of moving the cursor", () => {
|
|
85
|
+
assert.strictEqual(
|
|
86
|
+
run({ key: "ArrowDown", shiftKey: true }).action,
|
|
87
|
+
"extendSelectDown",
|
|
88
|
+
);
|
|
89
|
+
assert.strictEqual(
|
|
90
|
+
run({ key: "ArrowUp", shiftKey: true }).action,
|
|
91
|
+
"extendSelectUp",
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("⌘A / Ctrl+A selects every loaded row", () => {
|
|
96
|
+
assert.strictEqual(run({ key: "a", metaKey: true }).action, "selectAll");
|
|
97
|
+
assert.strictEqual(run({ key: "a", ctrlKey: true }).action, "selectAll");
|
|
98
|
+
assert.strictEqual(
|
|
99
|
+
run({ key: "a", metaKey: true }).preventDefault,
|
|
100
|
+
true,
|
|
101
|
+
"the browser's select-all-text default must be replaced",
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("dispatchKey — controls keep their activation keys", () => {
|
|
107
|
+
// The regression behind #43: a global Enter binding cancelled the default
|
|
108
|
+
// action of whatever the user had tabbed to, so no button in the app could
|
|
109
|
+
// be activated from the keyboard.
|
|
110
|
+
test("Enter on a focused control is left to the control", () => {
|
|
111
|
+
const result = run({ key: "Enter", onControl: true });
|
|
112
|
+
assert.strictEqual(result.action, null);
|
|
113
|
+
assert.strictEqual(result.preventDefault, false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("Space on a focused control is left to the control", () => {
|
|
117
|
+
const result = run({ key: " ", onControl: true });
|
|
118
|
+
assert.strictEqual(result.action, null);
|
|
119
|
+
assert.strictEqual(result.preventDefault, false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("Enter and Space still drive the list away from a control", () => {
|
|
123
|
+
assert.strictEqual(run({ key: "Enter" }).action, "openFocused");
|
|
124
|
+
assert.strictEqual(run({ key: " " }).action, "toggleSelect");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("non-activation keys still fire from a focused control", () => {
|
|
128
|
+
// Tabbing to a toolbar button must not strand the rest of the keymap.
|
|
129
|
+
assert.strictEqual(run({ key: "j", onControl: true }).action, "focusNext");
|
|
130
|
+
assert.strictEqual(run({ key: "r", onControl: true }).action, "reply");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("an Enter released to a control still cancels a pending g prefix", () => {
|
|
134
|
+
assert.strictEqual(
|
|
135
|
+
run({ key: "Enter", onControl: true }, "g").nextPrefix,
|
|
136
|
+
null,
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
64
141
|
describe("dispatchKey — input suppression", () => {
|
|
65
142
|
test("plain keys are inert in an editable surface", () => {
|
|
66
143
|
assert.strictEqual(run({ key: "j", inEditable: true }).action, null);
|
|
@@ -98,8 +175,8 @@ describe("dispatchKey — modifiers", () => {
|
|
|
98
175
|
});
|
|
99
176
|
|
|
100
177
|
test("other meta combos are left to the browser", () => {
|
|
101
|
-
assert.strictEqual(run({ key: "a", metaKey: true }).action, null);
|
|
102
178
|
assert.strictEqual(run({ key: "c", metaKey: true }).action, null);
|
|
179
|
+
assert.strictEqual(run({ key: "f", metaKey: true }).action, null);
|
|
103
180
|
});
|
|
104
181
|
|
|
105
182
|
test("Shift+J / Shift+K extend the selection", () => {
|
|
@@ -19,6 +19,15 @@ export interface KeyStroke {
|
|
|
19
19
|
altKey: boolean;
|
|
20
20
|
/** Whether the event originated inside an editable surface. */
|
|
21
21
|
inEditable: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Whether the event originated on an activatable control that is not a
|
|
24
|
+
* message-list row — a button, link, `role="button"`, `<summary>`. Enter and
|
|
25
|
+
* Space are that control's activation keys, so the layer releases them; every
|
|
26
|
+
* other binding still fires. Without this a global Enter binding cancels the
|
|
27
|
+
* default action of whatever the user tabbed to, and no button in the app can
|
|
28
|
+
* be activated from the keyboard.
|
|
29
|
+
*/
|
|
30
|
+
onControl: boolean;
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
/** Pending sequence-prefix state. `"g"` means a `g` was pressed recently. */
|
|
@@ -69,7 +78,14 @@ interface PlainBinding {
|
|
|
69
78
|
const PLAIN_BINDINGS: Record<string, PlainBinding> = {
|
|
70
79
|
j: { action: "focusNext" },
|
|
71
80
|
k: { action: "focusPrevious" },
|
|
81
|
+
arrowdown: { action: "focusNext" },
|
|
82
|
+
arrowup: { action: "focusPrevious" },
|
|
83
|
+
home: { action: "focusFirst" },
|
|
84
|
+
end: { action: "focusLast" },
|
|
72
85
|
enter: { action: "openFocused" },
|
|
86
|
+
" ": { action: "toggleSelect" },
|
|
87
|
+
delete: { action: "delete" },
|
|
88
|
+
backspace: { action: "delete" },
|
|
73
89
|
u: { action: "toggleRead" },
|
|
74
90
|
x: { action: "toggleSelect" },
|
|
75
91
|
r: { action: "reply" },
|
|
@@ -118,12 +134,26 @@ export function dispatchKey(
|
|
|
118
134
|
return { ...NONE, nextPrefix: prefix === "g" ? null : prefix };
|
|
119
135
|
}
|
|
120
136
|
|
|
137
|
+
// Enter / Space belong to whatever control has focus. Releasing them here is
|
|
138
|
+
// what keeps Tab-to-a-button-then-Enter working anywhere in the app; the
|
|
139
|
+
// list's own rows are excluded from `onControl`, so the roving cursor still
|
|
140
|
+
// opens and selects.
|
|
141
|
+
if (stroke.onControl && (lower === "enter" || lower === " ")) {
|
|
142
|
+
return { ...NONE, nextPrefix: prefix === "g" ? null : prefix };
|
|
143
|
+
}
|
|
144
|
+
|
|
121
145
|
// ⌘N / Ctrl+N → compose. Checked before the prefix/plain tables so the
|
|
122
146
|
// browser's "new window" is the only thing we intercept among meta combos.
|
|
123
147
|
if (meta && lower === "n") {
|
|
124
148
|
return { action: "compose", nextPrefix: null, preventDefault: true };
|
|
125
149
|
}
|
|
126
150
|
|
|
151
|
+
// ⌘A / Ctrl+A → select every loaded row, replacing the browser's
|
|
152
|
+
// select-all-text default.
|
|
153
|
+
if (meta && lower === "a") {
|
|
154
|
+
return { action: "selectAll", nextPrefix: null, preventDefault: true };
|
|
155
|
+
}
|
|
156
|
+
|
|
127
157
|
// Any other meta/ctrl combo is left to the browser/OS.
|
|
128
158
|
if (meta) return { ...NONE, nextPrefix: prefix === "g" ? null : prefix };
|
|
129
159
|
|
|
@@ -145,13 +175,17 @@ export function dispatchKey(
|
|
|
145
175
|
return { action: null, nextPrefix: "g", preventDefault: true };
|
|
146
176
|
}
|
|
147
177
|
|
|
148
|
-
// Shift+J
|
|
149
|
-
if (stroke.shiftKey
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
178
|
+
// Shift+J/K and Shift+Arrow extend the selection.
|
|
179
|
+
if (stroke.shiftKey) {
|
|
180
|
+
const down = lower === "j" || lower === "arrowdown";
|
|
181
|
+
const up = lower === "k" || lower === "arrowup";
|
|
182
|
+
if (down || up) {
|
|
183
|
+
return {
|
|
184
|
+
action: down ? "extendSelectDown" : "extendSelectUp",
|
|
185
|
+
nextPrefix: null,
|
|
186
|
+
preventDefault: true,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
155
189
|
}
|
|
156
190
|
|
|
157
191
|
// Plain single-key bindings.
|
|
@@ -185,3 +219,26 @@ export function isEditableTarget(target: EventTarget | null): boolean {
|
|
|
185
219
|
target.isContentEditable
|
|
186
220
|
);
|
|
187
221
|
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Marks an element as a message-list row. Rows are anchors, so they would
|
|
225
|
+
* otherwise read as ordinary controls; the list owns Enter/Space on them.
|
|
226
|
+
*/
|
|
227
|
+
export const ROW_ATTRIBUTE = "data-message-row";
|
|
228
|
+
|
|
229
|
+
const CONTROL_SELECTOR =
|
|
230
|
+
'button, a[href], summary, [role="button"], [role="menuitem"], [role="tab"], [role="switch"], [role="checkbox"]';
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Whether the event target sits inside an activatable control that is not a
|
|
234
|
+
* message-list row. See {@link KeyStroke.onControl}.
|
|
235
|
+
*/
|
|
236
|
+
export function isControlTarget(target: EventTarget | null): boolean {
|
|
237
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
238
|
+
const control = target.closest(CONTROL_SELECTOR);
|
|
239
|
+
if (!control) return false;
|
|
240
|
+
// The nearest control wins. A row is an anchor and so matches the selector,
|
|
241
|
+
// but the list owns Enter/Space on its rows; a control nested *inside* a row
|
|
242
|
+
// (the select checkbox) is a control like any other and keeps its own keys.
|
|
243
|
+
return !control.hasAttribute(ROW_ATTRIBUTE);
|
|
244
|
+
}
|
package/src/lib/keymap.ts
CHANGED
|
@@ -13,12 +13,15 @@ export type TriageAction =
|
|
|
13
13
|
// list navigation / focus model
|
|
14
14
|
| "focusNext"
|
|
15
15
|
| "focusPrevious"
|
|
16
|
+
| "focusFirst"
|
|
17
|
+
| "focusLast"
|
|
16
18
|
| "openFocused"
|
|
17
19
|
| "back"
|
|
18
20
|
// selection
|
|
19
21
|
| "toggleSelect"
|
|
20
22
|
| "extendSelectDown"
|
|
21
23
|
| "extendSelectUp"
|
|
24
|
+
| "selectAll"
|
|
22
25
|
// message verbs (focused row / selection)
|
|
23
26
|
| "reply"
|
|
24
27
|
| "replyAll"
|
|
@@ -76,6 +79,14 @@ export const KEY_HINT_GROUPS: KeyHintGroup[] = [
|
|
|
76
79
|
keys: ["k"],
|
|
77
80
|
description: "Focus previous message",
|
|
78
81
|
},
|
|
82
|
+
{ action: "focusNext", keys: ["↓"], description: "Focus next message" },
|
|
83
|
+
{
|
|
84
|
+
action: "focusPrevious",
|
|
85
|
+
keys: ["↑"],
|
|
86
|
+
description: "Focus previous message",
|
|
87
|
+
},
|
|
88
|
+
{ action: "focusFirst", keys: ["Home"], description: "Focus first" },
|
|
89
|
+
{ action: "focusLast", keys: ["End"], description: "Focus last" },
|
|
79
90
|
{
|
|
80
91
|
action: "openFocused",
|
|
81
92
|
keys: ["Enter"],
|
|
@@ -88,6 +99,7 @@ export const KEY_HINT_GROUPS: KeyHintGroup[] = [
|
|
|
88
99
|
title: "Selection",
|
|
89
100
|
hints: [
|
|
90
101
|
{ action: "toggleSelect", keys: ["x"], description: "Toggle select" },
|
|
102
|
+
{ action: "toggleSelect", keys: ["Space"], description: "Toggle select" },
|
|
91
103
|
{
|
|
92
104
|
action: "extendSelectDown",
|
|
93
105
|
keys: ["Shift", "j"],
|
|
@@ -98,6 +110,17 @@ export const KEY_HINT_GROUPS: KeyHintGroup[] = [
|
|
|
98
110
|
keys: ["Shift", "k"],
|
|
99
111
|
description: "Extend selection up",
|
|
100
112
|
},
|
|
113
|
+
{
|
|
114
|
+
action: "extendSelectDown",
|
|
115
|
+
keys: ["Shift", "↓"],
|
|
116
|
+
description: "Extend selection down",
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
action: "extendSelectUp",
|
|
120
|
+
keys: ["Shift", "↑"],
|
|
121
|
+
description: "Extend selection up",
|
|
122
|
+
},
|
|
123
|
+
{ action: "selectAll", keys: ["⌘", "A"], description: "Select all" },
|
|
101
124
|
],
|
|
102
125
|
},
|
|
103
126
|
{
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { tabStopId } from "./list-focus.ts";
|
|
4
|
+
|
|
5
|
+
const ids = ["a", "b", "c"];
|
|
6
|
+
|
|
7
|
+
describe("tabStopId", () => {
|
|
8
|
+
test("the cursor row holds the tab stop", () => {
|
|
9
|
+
assert.strictEqual(tabStopId(ids, "b"), "b");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("an untouched list puts the tab stop on the first row", () => {
|
|
13
|
+
assert.strictEqual(tabStopId(ids, undefined), "a");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("a cursor that no longer exists falls back to the first row", () => {
|
|
17
|
+
// After a delete or a refetch the cursor can name a row that is gone; the
|
|
18
|
+
// list must keep a tab stop or Tab skips over it entirely.
|
|
19
|
+
assert.strictEqual(tabStopId(ids, "zzz"), "a");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("an empty list has no tab stop", () => {
|
|
23
|
+
assert.strictEqual(tabStopId([], "a"), undefined);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Roving-tabindex math for the message list.
|
|
3
|
+
*
|
|
4
|
+
* A virtualized list of hundreds of rows must expose exactly one tab stop:
|
|
5
|
+
* Tab then moves focus into the list at the keyboard cursor and Shift+Tab moves
|
|
6
|
+
* back out to the side panel, instead of walking every row. Pure so the rule is
|
|
7
|
+
* testable without a DOM.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The id of the row that holds the list's tab stop. The keyboard cursor owns it
|
|
12
|
+
* while it points at a loaded row; otherwise the first row does, so an untouched
|
|
13
|
+
* list is still reachable with one Tab.
|
|
14
|
+
*/
|
|
15
|
+
export function tabStopId(
|
|
16
|
+
orderedIds: string[],
|
|
17
|
+
focusedId: string | undefined,
|
|
18
|
+
): string | undefined {
|
|
19
|
+
if (orderedIds.length === 0) return undefined;
|
|
20
|
+
if (focusedId !== undefined && orderedIds.includes(focusedId)) {
|
|
21
|
+
return focusedId;
|
|
22
|
+
}
|
|
23
|
+
return orderedIds[0];
|
|
24
|
+
}
|
package/src/routes/mail.tsx
CHANGED
|
@@ -298,7 +298,8 @@ function MailLayout() {
|
|
|
298
298
|
/>
|
|
299
299
|
</div>
|
|
300
300
|
) : onBriefRoute ? (
|
|
301
|
-
// Daily brief (/mail/) — no mailboxId param;
|
|
301
|
+
// Daily brief (/mail/) — no mailboxId param; same 3-pane layout as a
|
|
302
|
+
// mailbox: an open message has an intelligence rail here too (#52).
|
|
302
303
|
<BriefPane selectedMessageId={mobileSelectedMessageId}>
|
|
303
304
|
{isSinglePane ? (
|
|
304
305
|
<AppShellSlotted
|
|
@@ -316,7 +317,9 @@ function MailLayout() {
|
|
|
316
317
|
topBar={topBar}
|
|
317
318
|
list={<BriefPane.List />}
|
|
318
319
|
reading={<BriefPane.Reading />}
|
|
320
|
+
intelligence={<BriefPane.Intelligence />}
|
|
319
321
|
intelligenceOpen={intelligenceOpen}
|
|
322
|
+
hasThread={Boolean(mobileSelectedMessageId)}
|
|
320
323
|
overlay={overlayContent}
|
|
321
324
|
skeleton={<AppShellSkeleton />}
|
|
322
325
|
isLoading={isLoading || hasNoAccounts}
|
|
@@ -326,7 +329,7 @@ function MailLayout() {
|
|
|
326
329
|
</BriefPane>
|
|
327
330
|
) : onFlaggedRoute ? (
|
|
328
331
|
// Flagged virtual mailbox (/mail/flagged) — flat starred list across
|
|
329
|
-
// accounts;
|
|
332
|
+
// accounts; same slots as the brief, intelligence rail included.
|
|
330
333
|
<FlaggedPane selectedMessageId={mobileSelectedMessageId}>
|
|
331
334
|
{isSinglePane ? (
|
|
332
335
|
<AppShellSlotted
|
|
@@ -344,7 +347,9 @@ function MailLayout() {
|
|
|
344
347
|
topBar={topBar}
|
|
345
348
|
list={<FlaggedPane.List />}
|
|
346
349
|
reading={<FlaggedPane.Reading />}
|
|
350
|
+
intelligence={<FlaggedPane.Intelligence />}
|
|
347
351
|
intelligenceOpen={intelligenceOpen}
|
|
352
|
+
hasThread={Boolean(mobileSelectedMessageId)}
|
|
348
353
|
overlay={overlayContent}
|
|
349
354
|
skeleton={<AppShellSkeleton />}
|
|
350
355
|
isLoading={isLoading || hasNoAccounts}
|
|
@@ -376,6 +381,7 @@ function MailLayout() {
|
|
|
376
381
|
reading={<MailboxPane.Reading />}
|
|
377
382
|
intelligence={<MailboxPane.Intelligence />}
|
|
378
383
|
intelligenceOpen={intelligenceOpen}
|
|
384
|
+
hasThread={Boolean(mobileSelectedMessageId)}
|
|
379
385
|
overlay={overlayContent}
|
|
380
386
|
skeleton={<AppShellSkeleton />}
|
|
381
387
|
isLoading={isLoading || hasNoAccounts}
|