@remit/web-client 0.0.49 → 0.0.50
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 +5 -3
- package/src/components/mail/BriefPane.tsx +98 -2
- package/src/components/mail/DailyBrief.tsx +44 -12
- package/src/components/mail/FlaggedList.tsx +51 -27
- package/src/components/mail/FlaggedPane.tsx +101 -2
- package/src/components/mail/MailboxPane.tsx +28 -77
- package/src/components/mail/MessageRow.tsx +20 -8
- package/src/components/mail/ThreadListInteraction.test.ts +199 -0
- package/src/components/mail/ThreadListInteraction.tsx +408 -0
- package/src/hooks/useDeleteMessages.ts +16 -4
- package/src/hooks/useListCursor.test.ts +273 -0
- package/src/hooks/useListCursor.ts +226 -0
- package/src/hooks/useMarkAsRead.ts +15 -3
- package/src/hooks/useTriageLayer.ts +148 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared list cursor (#149) — the roving keyboard cursor and multi-selection
|
|
3
|
+
* every thread list drives. Mounted against jsdom rather than `renderToString`,
|
|
4
|
+
* since the state only moves in response to real calls across renders.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
8
|
+
import type { JSDOM } from "jsdom";
|
|
9
|
+
import { act, createElement } from "react";
|
|
10
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
11
|
+
import { type ListCursor, useListCursor } from "./useListCursor";
|
|
12
|
+
|
|
13
|
+
const IDS = ["m1", "m2", "m3", "m4"];
|
|
14
|
+
|
|
15
|
+
let dom: JSDOM;
|
|
16
|
+
let container: HTMLElement;
|
|
17
|
+
let root: Root;
|
|
18
|
+
|
|
19
|
+
before(async () => {
|
|
20
|
+
const { JSDOM: JSDOMCtor } = await import("jsdom");
|
|
21
|
+
dom = new JSDOMCtor(
|
|
22
|
+
"<!doctype html><html><body><div id=root></div></body></html>",
|
|
23
|
+
{ url: "http://localhost/", pretendToBeVisual: true },
|
|
24
|
+
);
|
|
25
|
+
globalThis.window = dom.window as unknown as typeof globalThis.window;
|
|
26
|
+
globalThis.document = dom.window.document;
|
|
27
|
+
globalThis.HTMLElement = dom.window.HTMLElement;
|
|
28
|
+
globalThis.Element = dom.window.Element;
|
|
29
|
+
Object.defineProperty(globalThis, "navigator", {
|
|
30
|
+
value: dom.window.navigator,
|
|
31
|
+
configurable: true,
|
|
32
|
+
});
|
|
33
|
+
(
|
|
34
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
35
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
after(() => dom.window.close());
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
container = dom.window.document.getElementById(
|
|
42
|
+
"root",
|
|
43
|
+
) as unknown as HTMLElement;
|
|
44
|
+
container.innerHTML = "";
|
|
45
|
+
root = createRoot(container);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
act(() => root.unmount());
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Mount the hook and hand back a live handle. Every read goes through
|
|
54
|
+
* `current()` so a test never asserts against a stale render.
|
|
55
|
+
*/
|
|
56
|
+
function mountCursor(options: {
|
|
57
|
+
orderedIds?: string[];
|
|
58
|
+
isDesktop?: boolean;
|
|
59
|
+
initialFocusedId?: string;
|
|
60
|
+
}): () => ListCursor {
|
|
61
|
+
let latest: ListCursor | undefined;
|
|
62
|
+
const Probe = () => {
|
|
63
|
+
latest = useListCursor({
|
|
64
|
+
orderedIds: options.orderedIds ?? IDS,
|
|
65
|
+
isDesktop: options.isDesktop ?? true,
|
|
66
|
+
initialFocusedId: options.initialFocusedId,
|
|
67
|
+
});
|
|
68
|
+
return null;
|
|
69
|
+
};
|
|
70
|
+
act(() => root.render(createElement(Probe)));
|
|
71
|
+
return () => {
|
|
72
|
+
if (!latest) throw new Error("cursor not mounted");
|
|
73
|
+
return latest;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("useListCursor — the roving cursor", () => {
|
|
78
|
+
it("starts on the open thread and walks the list with next/previous", () => {
|
|
79
|
+
const cursor = mountCursor({ initialFocusedId: "m2" });
|
|
80
|
+
assert.equal(cursor().focusedMessageId, "m2");
|
|
81
|
+
|
|
82
|
+
act(() => cursor().focusNext());
|
|
83
|
+
assert.equal(cursor().focusedMessageId, "m3");
|
|
84
|
+
|
|
85
|
+
act(() => cursor().focusPrevious());
|
|
86
|
+
assert.equal(cursor().focusedMessageId, "m2");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("starts at the top when nothing is focused yet", () => {
|
|
90
|
+
const cursor = mountCursor({});
|
|
91
|
+
assert.equal(cursor().focusedMessageId, undefined);
|
|
92
|
+
|
|
93
|
+
act(() => cursor().focusNext());
|
|
94
|
+
assert.equal(cursor().focusedMessageId, "m1");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("clamps at both ends rather than wrapping", () => {
|
|
98
|
+
const cursor = mountCursor({ initialFocusedId: "m1" });
|
|
99
|
+
act(() => cursor().focusPrevious());
|
|
100
|
+
assert.equal(cursor().focusedMessageId, "m1");
|
|
101
|
+
|
|
102
|
+
act(() => cursor().focusLast());
|
|
103
|
+
assert.equal(cursor().focusedMessageId, "m4");
|
|
104
|
+
act(() => cursor().focusNext());
|
|
105
|
+
assert.equal(cursor().focusedMessageId, "m4");
|
|
106
|
+
|
|
107
|
+
act(() => cursor().focusFirst());
|
|
108
|
+
assert.equal(cursor().focusedMessageId, "m1");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("moves nothing when the list is empty", () => {
|
|
112
|
+
const cursor = mountCursor({ orderedIds: [] });
|
|
113
|
+
act(() => cursor().focusNext());
|
|
114
|
+
act(() => cursor().focusLast());
|
|
115
|
+
assert.equal(cursor().focusedMessageId, undefined);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("asks for DOM focus on its own moves, not on a pointer move", () => {
|
|
119
|
+
const cursor = mountCursor({ initialFocusedId: "m1" });
|
|
120
|
+
act(() => cursor().focusNext());
|
|
121
|
+
assert.equal(cursor().pendingDomFocusRef.current, "m2");
|
|
122
|
+
assert.equal(cursor().cursorMovedByPointerRef.current, false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("useListCursor — selection", () => {
|
|
127
|
+
it("x toggles the row under the cursor", () => {
|
|
128
|
+
const cursor = mountCursor({ initialFocusedId: "m2" });
|
|
129
|
+
act(() => cursor().toggleFocusedSelection());
|
|
130
|
+
assert.deepEqual([...cursor().selection.selectedIds], ["m2"]);
|
|
131
|
+
|
|
132
|
+
act(() => cursor().toggleFocusedSelection());
|
|
133
|
+
assert.equal(cursor().selection.selectedCount, 0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("shift-extend moves the cursor and grows the range from the anchor", () => {
|
|
137
|
+
const cursor = mountCursor({ initialFocusedId: "m2" });
|
|
138
|
+
|
|
139
|
+
// The first press has no anchor yet, so the row it lands on becomes both
|
|
140
|
+
// the anchor and the whole range.
|
|
141
|
+
act(() => cursor().extendRangeDown());
|
|
142
|
+
assert.deepEqual([...cursor().selection.selectedIds], ["m3"]);
|
|
143
|
+
assert.equal(cursor().focusedMessageId, "m3");
|
|
144
|
+
assert.equal(cursor().selection.anchorId, "m3");
|
|
145
|
+
|
|
146
|
+
// Consecutive presses extend from that anchor.
|
|
147
|
+
act(() => cursor().extendRangeDown());
|
|
148
|
+
assert.deepEqual([...cursor().selection.selectedIds].sort(), ["m3", "m4"]);
|
|
149
|
+
assert.equal(cursor().focusedMessageId, "m4");
|
|
150
|
+
assert.equal(cursor().selection.anchorId, "m3");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("shift-extend upward ranges back through the anchor", () => {
|
|
154
|
+
const cursor = mountCursor({ initialFocusedId: "m4" });
|
|
155
|
+
|
|
156
|
+
act(() => cursor().extendRangeUp());
|
|
157
|
+
assert.deepEqual([...cursor().selection.selectedIds], ["m3"]);
|
|
158
|
+
|
|
159
|
+
act(() => cursor().extendRangeUp());
|
|
160
|
+
assert.deepEqual([...cursor().selection.selectedIds].sort(), ["m2", "m3"]);
|
|
161
|
+
assert.equal(cursor().focusedMessageId, "m2");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("at the last row, extending takes that row and the cursor stays", () => {
|
|
165
|
+
const cursor = mountCursor({ initialFocusedId: "m4" });
|
|
166
|
+
act(() => cursor().extendRangeDown());
|
|
167
|
+
assert.deepEqual([...cursor().selection.selectedIds], ["m4"]);
|
|
168
|
+
assert.equal(cursor().focusedMessageId, "m4");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("select-all takes every row, and exiting clears it", () => {
|
|
172
|
+
const cursor = mountCursor({});
|
|
173
|
+
act(() => cursor().selectAllLoaded());
|
|
174
|
+
assert.equal(cursor().selection.selectedCount, IDS.length);
|
|
175
|
+
|
|
176
|
+
act(() => cursor().exitSelection());
|
|
177
|
+
assert.equal(cursor().selection.selectedCount, 0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("runs the caller's teardown before clearing", () => {
|
|
181
|
+
const order: string[] = [];
|
|
182
|
+
let latest: ListCursor | undefined;
|
|
183
|
+
const Probe = () => {
|
|
184
|
+
latest = useListCursor({
|
|
185
|
+
orderedIds: IDS,
|
|
186
|
+
isDesktop: true,
|
|
187
|
+
onExitSelection: () => order.push("teardown"),
|
|
188
|
+
});
|
|
189
|
+
return null;
|
|
190
|
+
};
|
|
191
|
+
act(() => root.render(createElement(Probe)));
|
|
192
|
+
act(() => latest?.selectAllLoaded());
|
|
193
|
+
act(() => latest?.exitSelection());
|
|
194
|
+
assert.deepEqual(order, ["teardown"]);
|
|
195
|
+
assert.equal(latest?.selection.selectedCount, 0);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("useListCursor — mouse selection semantics", () => {
|
|
200
|
+
it("shift-click ranges, cmd-click toggles, and both consume the click", () => {
|
|
201
|
+
const cursor = mountCursor({ initialFocusedId: "m1" });
|
|
202
|
+
|
|
203
|
+
let handled = false;
|
|
204
|
+
act(() => {
|
|
205
|
+
handled = cursor().handleRowSelect("m3", {
|
|
206
|
+
shiftKey: true,
|
|
207
|
+
metaKey: false,
|
|
208
|
+
ctrlKey: false,
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
assert.equal(handled, true);
|
|
212
|
+
assert.deepEqual([...cursor().selection.selectedIds].sort(), [
|
|
213
|
+
"m1",
|
|
214
|
+
"m2",
|
|
215
|
+
"m3",
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
act(() => {
|
|
219
|
+
handled = cursor().handleRowSelect("m4", {
|
|
220
|
+
shiftKey: false,
|
|
221
|
+
metaKey: true,
|
|
222
|
+
ctrlKey: false,
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
assert.equal(handled, true);
|
|
226
|
+
assert.ok(cursor().selection.selectedIds.has("m4"));
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("a plain click collapses the selection and lets the row open", () => {
|
|
230
|
+
const cursor = mountCursor({ initialFocusedId: "m1" });
|
|
231
|
+
act(() => cursor().selectAllLoaded());
|
|
232
|
+
|
|
233
|
+
let handled = true;
|
|
234
|
+
act(() => {
|
|
235
|
+
handled = cursor().handleRowSelect("m2", {
|
|
236
|
+
shiftKey: false,
|
|
237
|
+
metaKey: false,
|
|
238
|
+
ctrlKey: false,
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
assert.equal(handled, false, "navigation must proceed on a plain click");
|
|
242
|
+
assert.equal(cursor().selection.selectedCount, 0);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe("useListCursor — the device branch", () => {
|
|
247
|
+
it("is not in multi-select mode on desktop, whatever is selected", () => {
|
|
248
|
+
const cursor = mountCursor({ isDesktop: true });
|
|
249
|
+
act(() => cursor().selectAllLoaded());
|
|
250
|
+
assert.equal(cursor().isMultiSelectMode, false);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("enters multi-select mode on touch as soon as something is selected", () => {
|
|
254
|
+
const cursor = mountCursor({ isDesktop: false });
|
|
255
|
+
assert.equal(cursor().isMultiSelectMode, false);
|
|
256
|
+
|
|
257
|
+
act(() => cursor().selection.select("m2"));
|
|
258
|
+
assert.equal(cursor().isMultiSelectMode, true);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("on touch, in multi-select mode, next/previous toggle instead of moving", () => {
|
|
262
|
+
const cursor = mountCursor({ isDesktop: false, initialFocusedId: "m1" });
|
|
263
|
+
act(() => cursor().selection.select("m1"));
|
|
264
|
+
|
|
265
|
+
act(() => cursor().focusNext());
|
|
266
|
+
assert.equal(
|
|
267
|
+
cursor().focusedMessageId,
|
|
268
|
+
"m1",
|
|
269
|
+
"the cursor stays put in multi-select mode",
|
|
270
|
+
);
|
|
271
|
+
assert.deepEqual([...cursor().selection.selectedIds].sort(), ["m1", "m2"]);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useListCursor — the roving keyboard cursor and multi-selection of a thread
|
|
3
|
+
* list, independent of how the list renders.
|
|
4
|
+
*
|
|
5
|
+
* The mailbox list, the daily brief and Flagged all need it. It used to exist
|
|
6
|
+
* only inside `MessageList`, so the brief and Flagged had no cursor and no
|
|
7
|
+
* selection at all (#149). Everything here is list-shape agnostic: it works off
|
|
8
|
+
* the ordered message ids, so a virtualized flat list and a sectioned brief
|
|
9
|
+
* drive the same state.
|
|
10
|
+
*
|
|
11
|
+
* DOM concerns stay with the caller. `pendingDomFocusRef` names the row that
|
|
12
|
+
* should take real browser focus once it is rendered, and
|
|
13
|
+
* `cursorMovedByPointerRef` records whether the last move came from a click, so
|
|
14
|
+
* a list that scrolls its cursor into view can skip doing so for pointer moves.
|
|
15
|
+
*/
|
|
16
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
17
|
+
import {
|
|
18
|
+
nextFocusId,
|
|
19
|
+
type SelectionModifiers,
|
|
20
|
+
useSelection,
|
|
21
|
+
} from "@/hooks/useSelection";
|
|
22
|
+
import { deriveIsMultiSelectMode } from "@/lib/selection-mode";
|
|
23
|
+
|
|
24
|
+
interface UseListCursorOptions {
|
|
25
|
+
/** Message ids in display order. */
|
|
26
|
+
orderedIds: string[];
|
|
27
|
+
isDesktop: boolean;
|
|
28
|
+
/** Seeds the cursor — normally the open thread. */
|
|
29
|
+
initialFocusedId?: string;
|
|
30
|
+
/** Extra teardown run before the selection is cleared. */
|
|
31
|
+
onExitSelection?: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ListCursor {
|
|
35
|
+
focusedMessageId: string | undefined;
|
|
36
|
+
setFocusedMessageId: (id: string | undefined) => void;
|
|
37
|
+
focusIndex: number;
|
|
38
|
+
pendingDomFocusRef: React.RefObject<string | null>;
|
|
39
|
+
cursorMovedByPointerRef: React.RefObject<boolean>;
|
|
40
|
+
selection: ReturnType<typeof useSelection>;
|
|
41
|
+
isMultiSelectMode: boolean;
|
|
42
|
+
exitSelection: () => void;
|
|
43
|
+
moveFocusToIndex: (index: number) => void;
|
|
44
|
+
focusNext: () => void;
|
|
45
|
+
focusPrevious: () => void;
|
|
46
|
+
focusFirst: () => void;
|
|
47
|
+
focusLast: () => void;
|
|
48
|
+
toggleFocusedSelection: () => void;
|
|
49
|
+
extendRangeUp: () => void;
|
|
50
|
+
extendRangeDown: () => void;
|
|
51
|
+
selectAllLoaded: () => void;
|
|
52
|
+
/**
|
|
53
|
+
* Desktop mouse selection semantics (Apple Mail / Gmail model). Returns true
|
|
54
|
+
* when selection handled the click — the caller must then suppress the
|
|
55
|
+
* row's navigation; false for a plain click.
|
|
56
|
+
*/
|
|
57
|
+
handleRowSelect: (
|
|
58
|
+
messageId: string,
|
|
59
|
+
modifiers: SelectionModifiers,
|
|
60
|
+
) => boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const useListCursor = ({
|
|
64
|
+
orderedIds,
|
|
65
|
+
isDesktop,
|
|
66
|
+
initialFocusedId,
|
|
67
|
+
onExitSelection,
|
|
68
|
+
}: UseListCursorOptions): ListCursor => {
|
|
69
|
+
// The keyboard "where am I" pointer, distinct from the open thread
|
|
70
|
+
// (`selectedMessageId` in the URL). j/k move this cursor without opening;
|
|
71
|
+
// Enter opens the focused row. It seeds from the open thread so opening a
|
|
72
|
+
// message also focuses its row.
|
|
73
|
+
const [focusedMessageId, setFocusedMessageId] = useState<string | undefined>(
|
|
74
|
+
initialFocusedId,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const selection = useSelection();
|
|
78
|
+
const {
|
|
79
|
+
selectedCount,
|
|
80
|
+
toggle: toggleCheck,
|
|
81
|
+
clearSelection,
|
|
82
|
+
selectRange,
|
|
83
|
+
setAnchor,
|
|
84
|
+
selectAll,
|
|
85
|
+
} = selection;
|
|
86
|
+
|
|
87
|
+
// The selection count is the only source of truth for whether the list is in
|
|
88
|
+
// multi-select mode (#115). A separate flag needs an effect to reconcile it
|
|
89
|
+
// back to the count, and across that render the two disagree.
|
|
90
|
+
const isMultiSelectMode = deriveIsMultiSelectMode(selectedCount, isDesktop);
|
|
91
|
+
|
|
92
|
+
// Set when a keyboard command moves the cursor. Real DOM focus then follows
|
|
93
|
+
// it onto the row once rendered, so the browser's own focus — and therefore
|
|
94
|
+
// Tab, Shift+Tab and the focus ring — agree with what the list highlights
|
|
95
|
+
// (#43).
|
|
96
|
+
const pendingDomFocusRef = useRef<string | null>(null);
|
|
97
|
+
// Whether the cursor's last move came from a row taking DOM focus (a click)
|
|
98
|
+
// rather than a command. Scrolling for a click moves the row out from under
|
|
99
|
+
// the pointer between mousedown and click, so the click lands on empty space
|
|
100
|
+
// and nothing opens (#85).
|
|
101
|
+
const cursorMovedByPointerRef = useRef(false);
|
|
102
|
+
|
|
103
|
+
const exitSelection = useCallback(() => {
|
|
104
|
+
onExitSelection?.();
|
|
105
|
+
clearSelection();
|
|
106
|
+
}, [clearSelection, onExitSelection]);
|
|
107
|
+
|
|
108
|
+
const focusIndex = useMemo(
|
|
109
|
+
() => (focusedMessageId ? orderedIds.indexOf(focusedMessageId) : -1),
|
|
110
|
+
[orderedIds, focusedMessageId],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// Move the cursor by index. In multi-select mode (mobile) j/k toggle
|
|
114
|
+
// selection rather than moving a cursor.
|
|
115
|
+
const moveFocusToIndex = useCallback(
|
|
116
|
+
(index: number) => {
|
|
117
|
+
if (index < 0 || index >= orderedIds.length) return;
|
|
118
|
+
const messageId = orderedIds[index];
|
|
119
|
+
if (isMultiSelectMode) {
|
|
120
|
+
toggleCheck(messageId);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
pendingDomFocusRef.current = messageId;
|
|
124
|
+
cursorMovedByPointerRef.current = false;
|
|
125
|
+
setFocusedMessageId(messageId);
|
|
126
|
+
},
|
|
127
|
+
[orderedIds, isMultiSelectMode, toggleCheck],
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const focusNext = useCallback(() => {
|
|
131
|
+
if (orderedIds.length === 0) return;
|
|
132
|
+
moveFocusToIndex(
|
|
133
|
+
focusIndex < 0 ? 0 : Math.min(focusIndex + 1, orderedIds.length - 1),
|
|
134
|
+
);
|
|
135
|
+
}, [orderedIds.length, focusIndex, moveFocusToIndex]);
|
|
136
|
+
|
|
137
|
+
const focusPrevious = useCallback(() => {
|
|
138
|
+
if (orderedIds.length === 0) return;
|
|
139
|
+
moveFocusToIndex(focusIndex <= 0 ? 0 : focusIndex - 1);
|
|
140
|
+
}, [orderedIds.length, focusIndex, moveFocusToIndex]);
|
|
141
|
+
|
|
142
|
+
const focusFirst = useCallback(() => moveFocusToIndex(0), [moveFocusToIndex]);
|
|
143
|
+
const focusLast = useCallback(
|
|
144
|
+
() => moveFocusToIndex(orderedIds.length - 1),
|
|
145
|
+
[moveFocusToIndex, orderedIds.length],
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const toggleFocusedSelection = useCallback(() => {
|
|
149
|
+
if (focusedMessageId) toggleCheck(focusedMessageId);
|
|
150
|
+
}, [focusedMessageId, toggleCheck]);
|
|
151
|
+
|
|
152
|
+
const handleRowSelect = useCallback(
|
|
153
|
+
(messageId: string, modifiers: SelectionModifiers): boolean => {
|
|
154
|
+
if (modifiers.shiftKey) {
|
|
155
|
+
// The open/focused row is the fallback origin when the stored anchor
|
|
156
|
+
// has been filtered or searched out of the visible list, so the first
|
|
157
|
+
// shift-click still ranges from where the user is (#142, #144).
|
|
158
|
+
selectRange(orderedIds, messageId, focusedMessageId);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
if (modifiers.metaKey || modifiers.ctrlKey) {
|
|
162
|
+
toggleCheck(messageId);
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
// Plain click: collapse any multi-selection and let navigation proceed.
|
|
166
|
+
// The clicked row becomes the next anchor for a subsequent shift-click,
|
|
167
|
+
// but is NOT added to the checkbox set (no toolbar on a plain open).
|
|
168
|
+
exitSelection();
|
|
169
|
+
setAnchor(messageId);
|
|
170
|
+
return false;
|
|
171
|
+
},
|
|
172
|
+
[
|
|
173
|
+
orderedIds,
|
|
174
|
+
focusedMessageId,
|
|
175
|
+
selectRange,
|
|
176
|
+
toggleCheck,
|
|
177
|
+
exitSelection,
|
|
178
|
+
setAnchor,
|
|
179
|
+
],
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// Shift+arrow moves the cursor one row and adds the row it lands on to the
|
|
183
|
+
// range — the keyboard equivalent of shift-click. The first press seeds the
|
|
184
|
+
// anchor on that row; consecutive presses extend from it. The range only
|
|
185
|
+
// grows, so reversing direction ranges back through the anchor rather than
|
|
186
|
+
// giving rows up.
|
|
187
|
+
const extendRange = useCallback(
|
|
188
|
+
(direction: -1 | 1) => {
|
|
189
|
+
const target = nextFocusId(orderedIds, focusedMessageId, direction);
|
|
190
|
+
if (target === undefined) return;
|
|
191
|
+
selectRange(orderedIds, target);
|
|
192
|
+
pendingDomFocusRef.current = target;
|
|
193
|
+
cursorMovedByPointerRef.current = false;
|
|
194
|
+
setFocusedMessageId(target);
|
|
195
|
+
},
|
|
196
|
+
[orderedIds, focusedMessageId, selectRange],
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const extendRangeUp = useCallback(() => extendRange(-1), [extendRange]);
|
|
200
|
+
const extendRangeDown = useCallback(() => extendRange(1), [extendRange]);
|
|
201
|
+
|
|
202
|
+
const selectAllLoaded = useCallback(() => {
|
|
203
|
+
if (orderedIds.length > 0) selectAll(orderedIds);
|
|
204
|
+
}, [orderedIds, selectAll]);
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
focusedMessageId,
|
|
208
|
+
setFocusedMessageId,
|
|
209
|
+
focusIndex,
|
|
210
|
+
pendingDomFocusRef,
|
|
211
|
+
cursorMovedByPointerRef,
|
|
212
|
+
selection,
|
|
213
|
+
isMultiSelectMode,
|
|
214
|
+
exitSelection,
|
|
215
|
+
moveFocusToIndex,
|
|
216
|
+
focusNext,
|
|
217
|
+
focusPrevious,
|
|
218
|
+
focusFirst,
|
|
219
|
+
focusLast,
|
|
220
|
+
toggleFocusedSelection,
|
|
221
|
+
extendRangeUp,
|
|
222
|
+
extendRangeDown,
|
|
223
|
+
selectAllLoaded,
|
|
224
|
+
handleRowSelect,
|
|
225
|
+
};
|
|
226
|
+
};
|
|
@@ -287,8 +287,14 @@ export const useMarkAsRead = ({
|
|
|
287
287
|
export const useToggleReadFor = (options: {
|
|
288
288
|
mailboxId: string;
|
|
289
289
|
accountId?: string;
|
|
290
|
+
/**
|
|
291
|
+
* The threads the ids may come from, when the caller has them. A selection in
|
|
292
|
+
* the brief or Flagged spans mailboxes, so the listings to invalidate are the
|
|
293
|
+
* ones each message actually lives in.
|
|
294
|
+
*/
|
|
295
|
+
messages?: RemitImapThreadMessageResponse[];
|
|
290
296
|
}) => {
|
|
291
|
-
const { mailboxId, accountId } = options;
|
|
297
|
+
const { mailboxId, accountId, messages } = options;
|
|
292
298
|
const queryClient = useQueryClient();
|
|
293
299
|
const { pushError } = useErrorBanners();
|
|
294
300
|
|
|
@@ -302,10 +308,16 @@ export const useToggleReadFor = (options: {
|
|
|
302
308
|
error,
|
|
303
309
|
});
|
|
304
310
|
},
|
|
305
|
-
onSettled: () => {
|
|
311
|
+
onSettled: (_data, _error, variables) => {
|
|
306
312
|
invalidateThreadListQueries(
|
|
307
313
|
queryClient,
|
|
308
|
-
threadListCacheKeys(
|
|
314
|
+
threadListCacheKeys(
|
|
315
|
+
resolveMailboxesForMessages(
|
|
316
|
+
variables.body.messageIds ?? [],
|
|
317
|
+
messages ?? [],
|
|
318
|
+
mailboxId,
|
|
319
|
+
),
|
|
320
|
+
),
|
|
309
321
|
);
|
|
310
322
|
if (accountId) {
|
|
311
323
|
queryClient.invalidateQueries({
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pane-level interaction layer: the bridge between a mounted list and the
|
|
3
|
+
* global keyboard dispatcher, plus next/previous adjacency.
|
|
4
|
+
*
|
|
5
|
+
* The list publishes `MessageListCommands` into `listCommandsRef` and reports
|
|
6
|
+
* its cursor and selection through `onTriageContextChange`; the keyboard hook
|
|
7
|
+
* registers the navigation and selection keys only while a list is serving
|
|
8
|
+
* them, so with no list mounted Enter, Space and ⌘A stay with the browser.
|
|
9
|
+
*
|
|
10
|
+
* This lived inside `MailboxPane`, which is why the brief and Flagged had a
|
|
11
|
+
* keyboard hint bar and no keyboard (#149). Every pane mounts it now.
|
|
12
|
+
*
|
|
13
|
+
* It is two hooks because a pane's verbs are aimed at the focused row: the
|
|
14
|
+
* context comes first, the pane builds its handlers from it, and the keyboard
|
|
15
|
+
* registration comes last.
|
|
16
|
+
*/
|
|
17
|
+
import { type RefObject, useCallback, useRef, useState } from "react";
|
|
18
|
+
import type { MessageListCommands } from "@/components/mail/MessageList";
|
|
19
|
+
import {
|
|
20
|
+
type TriageHandlers,
|
|
21
|
+
useTriageKeyboard,
|
|
22
|
+
} from "@/hooks/useTriageKeyboard";
|
|
23
|
+
import { adjacentMessageId } from "@/lib/adjacent-message";
|
|
24
|
+
|
|
25
|
+
export interface TriageContextUpdate {
|
|
26
|
+
focusedMessageId: string | undefined;
|
|
27
|
+
selectedIds: string[];
|
|
28
|
+
hasList: boolean;
|
|
29
|
+
blocksKeyboard: boolean;
|
|
30
|
+
/** Row ids in display order, when the list knows them. Feeds adjacency. */
|
|
31
|
+
orderedIds?: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TriageContext {
|
|
35
|
+
listCommandsRef: RefObject<MessageListCommands | null>;
|
|
36
|
+
onTriageContextChange: (context: TriageContextUpdate) => void;
|
|
37
|
+
/** The list's roving cursor — where a verb with no selection is aimed. */
|
|
38
|
+
focusedMessageId: string | undefined;
|
|
39
|
+
/** The list's checkbox set — what a verb targets when it is non-empty. */
|
|
40
|
+
selectedIds: string[];
|
|
41
|
+
/** Row ids in display order, as last reported by the list. */
|
|
42
|
+
orderedIds: string[];
|
|
43
|
+
hasList: boolean;
|
|
44
|
+
blocksKeyboard: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const useTriageContext = (): TriageContext => {
|
|
48
|
+
const [focusedMessageId, setFocusedMessageId] = useState<string | undefined>(
|
|
49
|
+
undefined,
|
|
50
|
+
);
|
|
51
|
+
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
52
|
+
const [orderedIds, setOrderedIds] = useState<string[]>([]);
|
|
53
|
+
// Null whenever no list is mounted (reading-only phone view, drafts) — the
|
|
54
|
+
// keyboard layer then simply has nothing to drive.
|
|
55
|
+
const listCommandsRef = useRef<MessageListCommands | null>(null);
|
|
56
|
+
const [hasList, setHasList] = useState(false);
|
|
57
|
+
const [blocksKeyboard, setBlocksKeyboard] = useState(false);
|
|
58
|
+
|
|
59
|
+
const onTriageContextChange = useCallback((context: TriageContextUpdate) => {
|
|
60
|
+
setFocusedMessageId(context.focusedMessageId);
|
|
61
|
+
setSelectedIds(context.selectedIds);
|
|
62
|
+
if (context.orderedIds) setOrderedIds(context.orderedIds);
|
|
63
|
+
setHasList(context.hasList);
|
|
64
|
+
setBlocksKeyboard(context.blocksKeyboard);
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
listCommandsRef,
|
|
69
|
+
onTriageContextChange,
|
|
70
|
+
focusedMessageId,
|
|
71
|
+
selectedIds,
|
|
72
|
+
orderedIds,
|
|
73
|
+
hasList,
|
|
74
|
+
blocksKeyboard,
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
interface UseTriageLayerOptions {
|
|
79
|
+
context: TriageContext;
|
|
80
|
+
/** Message ids in display order — the adjacency source for next/previous. */
|
|
81
|
+
orderedIds: string[];
|
|
82
|
+
selectedMessageId: string | undefined;
|
|
83
|
+
/** Suspend the whole layer (e.g. a compose surface owns the keyboard). */
|
|
84
|
+
enabled?: boolean;
|
|
85
|
+
/** Close the open thread. Runs only when there is no selection to unwind. */
|
|
86
|
+
onClose: () => void;
|
|
87
|
+
/** The pane's own verbs — reply, star, delete and the rest. */
|
|
88
|
+
handlers: TriageHandlers;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface TriageLayer {
|
|
92
|
+
/** Esc: unwinds the selection first, then the open thread. */
|
|
93
|
+
goBack: () => void;
|
|
94
|
+
nextMessageId: string | undefined;
|
|
95
|
+
previousMessageId: string | undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const useTriageLayer = ({
|
|
99
|
+
context,
|
|
100
|
+
orderedIds,
|
|
101
|
+
selectedMessageId,
|
|
102
|
+
enabled = true,
|
|
103
|
+
onClose,
|
|
104
|
+
handlers,
|
|
105
|
+
}: UseTriageLayerOptions): TriageLayer => {
|
|
106
|
+
const { listCommandsRef, hasList, blocksKeyboard } = context;
|
|
107
|
+
|
|
108
|
+
const goBack = useCallback(() => {
|
|
109
|
+
if (listCommandsRef.current?.clearSelection()) return;
|
|
110
|
+
if (selectedMessageId) onClose();
|
|
111
|
+
}, [listCommandsRef, selectedMessageId, onClose]);
|
|
112
|
+
|
|
113
|
+
useTriageKeyboard({
|
|
114
|
+
// A modal owns the keyboard outright. Suspending the layer is what keeps a
|
|
115
|
+
// second Delete press from reaching a delete while the confirmation for the
|
|
116
|
+
// first one is still on screen.
|
|
117
|
+
enabled: enabled && !blocksKeyboard,
|
|
118
|
+
handlers: {
|
|
119
|
+
// Registered only while a list is mounted to serve them. An unregistered
|
|
120
|
+
// action is never preventDefault-ed, so with no list the browser keeps
|
|
121
|
+
// Enter, Space and ⌘A — select-all-text in the reading pane still works.
|
|
122
|
+
...(hasList
|
|
123
|
+
? {
|
|
124
|
+
focusNext: () => listCommandsRef.current?.focusNext(),
|
|
125
|
+
focusPrevious: () => listCommandsRef.current?.focusPrevious(),
|
|
126
|
+
focusFirst: () => listCommandsRef.current?.focusFirst(),
|
|
127
|
+
focusLast: () => listCommandsRef.current?.focusLast(),
|
|
128
|
+
openFocused: () => listCommandsRef.current?.openFocused(),
|
|
129
|
+
toggleSelect: () => listCommandsRef.current?.toggleSelect(),
|
|
130
|
+
extendSelectDown: () => listCommandsRef.current?.extendSelectDown(),
|
|
131
|
+
extendSelectUp: () => listCommandsRef.current?.extendSelectUp(),
|
|
132
|
+
selectAll: () => listCommandsRef.current?.selectAll(),
|
|
133
|
+
toggleDensity: () => listCommandsRef.current?.toggleDensity(),
|
|
134
|
+
}
|
|
135
|
+
: {}),
|
|
136
|
+
back: goBack,
|
|
137
|
+
...handlers,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
goBack,
|
|
143
|
+
nextMessageId:
|
|
144
|
+
adjacentMessageId(orderedIds, selectedMessageId, "next") ?? undefined,
|
|
145
|
+
previousMessageId:
|
|
146
|
+
adjacentMessageId(orderedIds, selectedMessageId, "previous") ?? undefined,
|
|
147
|
+
};
|
|
148
|
+
};
|