@remit/web-client 0.0.48 → 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 +149 -3
- package/src/components/mail/DailyBrief.tsx +44 -12
- package/src/components/mail/FlaggedList.tsx +51 -27
- package/src/components/mail/FlaggedPane.tsx +144 -3
- package/src/components/mail/MailboxPane.tsx +45 -143
- 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/useThreadActions.ts +101 -0
- package/src/hooks/useThreadMessageIds.ts +33 -0
- package/src/hooks/useTriageLayer.ts +148 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ThreadListInteraction (#149) — the cursor walks the rows that are on screen,
|
|
3
|
+
* and delete asks before it trashes anything.
|
|
4
|
+
*
|
|
5
|
+
* The brief's sections cap themselves behind "Show N more" and collapse from
|
|
6
|
+
* their headers, so what the consumer passed and what is rendered are different
|
|
7
|
+
* lists. These cases mount rows directly and change them, which is the same
|
|
8
|
+
* thing from the provider's point of view.
|
|
9
|
+
*/
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
12
|
+
import type { JSDOM } from "jsdom";
|
|
13
|
+
import { act, createElement, createRef, useState } from "react";
|
|
14
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
15
|
+
import type { MessageListCommands } from "./MessageList";
|
|
16
|
+
import { ThreadListInteraction } from "./ThreadListInteraction";
|
|
17
|
+
|
|
18
|
+
let dom: JSDOM;
|
|
19
|
+
let container: HTMLElement;
|
|
20
|
+
let root: Root;
|
|
21
|
+
|
|
22
|
+
before(async () => {
|
|
23
|
+
const { JSDOM: JSDOMCtor } = await import("jsdom");
|
|
24
|
+
dom = new JSDOMCtor(
|
|
25
|
+
"<!doctype html><html><body><div id=root></div></body></html>",
|
|
26
|
+
{ url: "http://localhost/", pretendToBeVisual: true },
|
|
27
|
+
);
|
|
28
|
+
globalThis.window = dom.window as unknown as typeof globalThis.window;
|
|
29
|
+
globalThis.document = dom.window.document;
|
|
30
|
+
globalThis.HTMLElement = dom.window.HTMLElement;
|
|
31
|
+
globalThis.Element = dom.window.Element;
|
|
32
|
+
globalThis.MutationObserver = dom.window.MutationObserver;
|
|
33
|
+
Object.defineProperty(globalThis, "navigator", {
|
|
34
|
+
value: dom.window.navigator,
|
|
35
|
+
configurable: true,
|
|
36
|
+
});
|
|
37
|
+
(
|
|
38
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
39
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
after(() => dom.window.close());
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
container = dom.window.document.getElementById(
|
|
46
|
+
"root",
|
|
47
|
+
) as unknown as HTMLElement;
|
|
48
|
+
container.innerHTML = "";
|
|
49
|
+
root = createRoot(container);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
act(() => root.unmount());
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const rowElements = (ids: string[]) =>
|
|
57
|
+
ids.map((id) =>
|
|
58
|
+
createElement("button", { key: id, type: "button", "data-message-id": id }),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Mount the provider over a set of rows, with a hook to change which rows are
|
|
63
|
+
* rendered afterwards — what "Show N more" and a collapsing section header do.
|
|
64
|
+
*/
|
|
65
|
+
function mountList(options: {
|
|
66
|
+
initialIds: string[];
|
|
67
|
+
onDeleteMessages?: (ids: string[]) => void;
|
|
68
|
+
}) {
|
|
69
|
+
const commandsRef = createRef<MessageListCommands | null>();
|
|
70
|
+
let setIds: ((ids: string[]) => void) | undefined;
|
|
71
|
+
const Harness = () => {
|
|
72
|
+
const [ids, set] = useState(options.initialIds);
|
|
73
|
+
setIds = set;
|
|
74
|
+
return createElement(
|
|
75
|
+
ThreadListInteraction,
|
|
76
|
+
{
|
|
77
|
+
selectedMessageId: undefined,
|
|
78
|
+
onOpen: () => undefined,
|
|
79
|
+
onDeleteMessages: options.onDeleteMessages,
|
|
80
|
+
commandsRef,
|
|
81
|
+
},
|
|
82
|
+
...rowElements(ids),
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
act(() => root.render(createElement(Harness)));
|
|
86
|
+
return {
|
|
87
|
+
commands: () => {
|
|
88
|
+
const commands = commandsRef.current;
|
|
89
|
+
if (!commands) throw new Error("commands not published");
|
|
90
|
+
return commands;
|
|
91
|
+
},
|
|
92
|
+
// The provider reads the rendered rows through a MutationObserver, whose
|
|
93
|
+
// callback lands on the microtask queue — an async act flushes both.
|
|
94
|
+
render: async (ids: string[]) => {
|
|
95
|
+
await act(async () => {
|
|
96
|
+
setIds?.(ids);
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
focusedId: () =>
|
|
100
|
+
(dom.window.document.activeElement as HTMLElement | null)?.dataset
|
|
101
|
+
.messageId,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe("ThreadListInteraction — the cursor follows the rendered rows", () => {
|
|
106
|
+
it("walks only the rows in the DOM", () => {
|
|
107
|
+
const list = mountList({ initialIds: ["m1", "m2", "m3"] });
|
|
108
|
+
|
|
109
|
+
act(() => list.commands().focusFirst());
|
|
110
|
+
assert.equal(list.focusedId(), "m1");
|
|
111
|
+
|
|
112
|
+
act(() => list.commands().focusLast());
|
|
113
|
+
assert.equal(list.focusedId(), "m3");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("stops at the last rendered row, not the last row in the data", () => {
|
|
117
|
+
// The capped case: three rows are on screen, more exist behind the
|
|
118
|
+
// expander. The cursor must not step past what is rendered.
|
|
119
|
+
const list = mountList({ initialIds: ["m1", "m2", "m3"] });
|
|
120
|
+
|
|
121
|
+
act(() => list.commands().focusLast());
|
|
122
|
+
act(() => list.commands().focusNext());
|
|
123
|
+
assert.equal(list.focusedId(), "m3");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("picks up rows revealed by the expander", async () => {
|
|
127
|
+
const list = mountList({ initialIds: ["m1", "m2", "m3"] });
|
|
128
|
+
act(() => list.commands().focusLast());
|
|
129
|
+
assert.equal(list.focusedId(), "m3");
|
|
130
|
+
|
|
131
|
+
await list.render(["m1", "m2", "m3", "m4", "m5"]);
|
|
132
|
+
|
|
133
|
+
act(() => list.commands().focusLast());
|
|
134
|
+
assert.equal(list.focusedId(), "m5");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("withdraws its commands when every row leaves", async () => {
|
|
138
|
+
const list = mountList({ initialIds: ["m1"] });
|
|
139
|
+
assert.ok(list.commands());
|
|
140
|
+
await list.render([]);
|
|
141
|
+
assert.throws(() => list.commands(), /commands not published/);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("ThreadListInteraction — delete confirms first", () => {
|
|
146
|
+
const confirmButton = () =>
|
|
147
|
+
Array.from(
|
|
148
|
+
dom.window.document.querySelectorAll<HTMLButtonElement>("button"),
|
|
149
|
+
).find((b) => b.textContent === "Move to Trash");
|
|
150
|
+
|
|
151
|
+
it("asks before it trashes anything", () => {
|
|
152
|
+
const deleted: string[][] = [];
|
|
153
|
+
const list = mountList({
|
|
154
|
+
initialIds: ["m1", "m2"],
|
|
155
|
+
onDeleteMessages: (ids) => deleted.push(ids),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
act(() => list.commands().focusFirst());
|
|
159
|
+
act(() => {
|
|
160
|
+
assert.equal(list.commands().requestDelete(), true);
|
|
161
|
+
});
|
|
162
|
+
assert.deepEqual(deleted, [], "nothing is deleted before confirming");
|
|
163
|
+
assert.ok(confirmButton(), "the confirmation is on screen");
|
|
164
|
+
|
|
165
|
+
act(() => confirmButton()?.click());
|
|
166
|
+
assert.deepEqual(deleted, [["m1"]]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("claims a second Delete rather than deleting behind the dialog", () => {
|
|
170
|
+
const deleted: string[][] = [];
|
|
171
|
+
const list = mountList({
|
|
172
|
+
initialIds: ["m1", "m2"],
|
|
173
|
+
onDeleteMessages: (ids) => deleted.push(ids),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
act(() => list.commands().focusFirst());
|
|
177
|
+
act(() => {
|
|
178
|
+
list.commands().requestDelete();
|
|
179
|
+
});
|
|
180
|
+
act(() => {
|
|
181
|
+
assert.equal(
|
|
182
|
+
list.commands().requestDelete(),
|
|
183
|
+
true,
|
|
184
|
+
"the second press belongs to the dialog",
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
assert.deepEqual(deleted, []);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("declines the keypress when there is nothing to delete", () => {
|
|
191
|
+
const list = mountList({
|
|
192
|
+
initialIds: ["m1"],
|
|
193
|
+
onDeleteMessages: () => undefined,
|
|
194
|
+
});
|
|
195
|
+
act(() => {
|
|
196
|
+
assert.equal(list.commands().requestDelete(), false);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ThreadListInteraction — the keyboard cursor and multi-selection for a list
|
|
3
|
+
* that renders its own rows.
|
|
4
|
+
*
|
|
5
|
+
* `MessageList` (the mailbox) owns a virtualizer and threads this state through
|
|
6
|
+
* row props. The brief and Flagged render rows through the kit's section
|
|
7
|
+
* components, which pass only the thread and its click handler, so the state
|
|
8
|
+
* reaches the row through context instead. Both drive the same `useListCursor`
|
|
9
|
+
* and publish the same `MessageListCommands`, so there is one definition of
|
|
10
|
+
* what j/k, x, shift-arrow and ⌘A do (#149).
|
|
11
|
+
*
|
|
12
|
+
* The cursor walks the rows that are actually on screen, read from the DOM. The
|
|
13
|
+
* brief's sections cap themselves at ten rows behind "Show N more", apply their
|
|
14
|
+
* own attribute chips, and collapse from their headers — none of which the data
|
|
15
|
+
* the consumer passed describes. A cursor walking that data steps onto rows that
|
|
16
|
+
* are not rendered: focus stops moving, the highlight disappears, and the next
|
|
17
|
+
* verb acts on a message the user cannot see.
|
|
18
|
+
*/
|
|
19
|
+
import {
|
|
20
|
+
createContext,
|
|
21
|
+
type ReactNode,
|
|
22
|
+
type RefObject,
|
|
23
|
+
useCallback,
|
|
24
|
+
useContext,
|
|
25
|
+
useEffect,
|
|
26
|
+
useMemo,
|
|
27
|
+
useRef,
|
|
28
|
+
useState,
|
|
29
|
+
} from "react";
|
|
30
|
+
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
|
31
|
+
import { useListCursor } from "@/hooks/useListCursor";
|
|
32
|
+
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
33
|
+
import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
|
|
34
|
+
import { formatDeleteToTrashTitle } from "@/lib/format";
|
|
35
|
+
import { tabStopId } from "@/lib/list-focus";
|
|
36
|
+
import type { MessageListCommands } from "./MessageList";
|
|
37
|
+
import type { MessageRowSelection } from "./MessageRow";
|
|
38
|
+
import { SelectionToolbar } from "./SelectionToolbar";
|
|
39
|
+
|
|
40
|
+
interface ThreadRowInteraction {
|
|
41
|
+
focused: boolean;
|
|
42
|
+
isTabStop: boolean;
|
|
43
|
+
isDesktop: boolean;
|
|
44
|
+
selection: MessageRowSelection;
|
|
45
|
+
onFocusRow: (messageId: string) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface ThreadListInteractionValue {
|
|
49
|
+
rowInteraction: (messageId: string) => ThreadRowInteraction;
|
|
50
|
+
selectedIds: Set<string>;
|
|
51
|
+
selectedCount: number;
|
|
52
|
+
exitSelection: () => void;
|
|
53
|
+
/** Opens the move-to-Trash confirmation for the current selection. */
|
|
54
|
+
requestDeleteSelection: () => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const ThreadListInteractionCtx =
|
|
58
|
+
createContext<ThreadListInteractionValue | null>(null);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Per-row cursor/selection state, or null outside a provider — the mailbox list
|
|
62
|
+
* passes the same state as explicit row props.
|
|
63
|
+
*/
|
|
64
|
+
export const useThreadRowInteraction = (
|
|
65
|
+
messageId: string,
|
|
66
|
+
): ThreadRowInteraction | null => {
|
|
67
|
+
const ctx = useContext(ThreadListInteractionCtx);
|
|
68
|
+
return ctx ? ctx.rowInteraction(messageId) : null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** The list's current selection, for a selection toolbar mounted alongside. */
|
|
72
|
+
export const useThreadListSelection = (): Omit<
|
|
73
|
+
ThreadListInteractionValue,
|
|
74
|
+
"rowInteraction"
|
|
75
|
+
> => {
|
|
76
|
+
const ctx = useContext(ThreadListInteractionCtx);
|
|
77
|
+
if (!ctx) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
"useThreadListSelection must be used inside <ThreadListInteraction>",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return ctx;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const ROW_SELECTOR = "[data-message-id]";
|
|
86
|
+
|
|
87
|
+
const readRowIds = (container: HTMLElement): string[] =>
|
|
88
|
+
Array.from(container.querySelectorAll<HTMLElement>(ROW_SELECTOR))
|
|
89
|
+
.map((row) => row.dataset.messageId)
|
|
90
|
+
.filter((id): id is string => id !== undefined);
|
|
91
|
+
|
|
92
|
+
const sameIds = (a: string[], b: string[]): boolean =>
|
|
93
|
+
a.length === b.length && a.every((id, i) => id === b[i]);
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The ids of the rows currently in the DOM, in document order, kept in step
|
|
97
|
+
* with the rendered list. Sections expand, collapse and cap themselves without
|
|
98
|
+
* the consumer's data changing, so a render pass is not enough of a signal — a
|
|
99
|
+
* MutationObserver is.
|
|
100
|
+
*/
|
|
101
|
+
const useRenderedRowIds = (
|
|
102
|
+
containerRef: RefObject<HTMLElement | null>,
|
|
103
|
+
): string[] => {
|
|
104
|
+
const [rowIds, setRowIds] = useState<string[]>([]);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
const container = containerRef.current;
|
|
108
|
+
if (!container) return;
|
|
109
|
+
|
|
110
|
+
const sync = () => {
|
|
111
|
+
const next = readRowIds(container);
|
|
112
|
+
setRowIds((prev) => (sameIds(prev, next) ? prev : next));
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
sync();
|
|
116
|
+
const observer = new MutationObserver(sync);
|
|
117
|
+
observer.observe(container, { childList: true, subtree: true });
|
|
118
|
+
return () => observer.disconnect();
|
|
119
|
+
}, [containerRef]);
|
|
120
|
+
|
|
121
|
+
return rowIds;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
interface ThreadListInteractionProps {
|
|
125
|
+
selectedMessageId: string | undefined;
|
|
126
|
+
/** Opens a row — the same navigation a click performs. */
|
|
127
|
+
onOpen: (messageId: string) => void;
|
|
128
|
+
/** Deletes a set of messages. Absent disables the delete key for this list. */
|
|
129
|
+
onDeleteMessages?: (messageIds: string[]) => void;
|
|
130
|
+
isDeleting?: boolean;
|
|
131
|
+
commandsRef?: RefObject<MessageListCommands | null>;
|
|
132
|
+
onTriageContextChange?: (context: TriageContextUpdate) => void;
|
|
133
|
+
children?: ReactNode;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function ThreadListInteraction({
|
|
137
|
+
selectedMessageId,
|
|
138
|
+
onOpen,
|
|
139
|
+
onDeleteMessages,
|
|
140
|
+
isDeleting = false,
|
|
141
|
+
commandsRef,
|
|
142
|
+
onTriageContextChange,
|
|
143
|
+
children,
|
|
144
|
+
}: ThreadListInteractionProps) {
|
|
145
|
+
const isDesktop = useIsDesktop();
|
|
146
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
147
|
+
const orderedIds = useRenderedRowIds(containerRef);
|
|
148
|
+
const cursor = useListCursor({
|
|
149
|
+
orderedIds,
|
|
150
|
+
isDesktop,
|
|
151
|
+
initialFocusedId: selectedMessageId,
|
|
152
|
+
});
|
|
153
|
+
const {
|
|
154
|
+
focusedMessageId,
|
|
155
|
+
setFocusedMessageId,
|
|
156
|
+
pendingDomFocusRef,
|
|
157
|
+
cursorMovedByPointerRef,
|
|
158
|
+
selection,
|
|
159
|
+
isMultiSelectMode,
|
|
160
|
+
exitSelection,
|
|
161
|
+
handleRowSelect,
|
|
162
|
+
} = cursor;
|
|
163
|
+
const { selectedIds, selectedCount, isSelected, toggle, select } = selection;
|
|
164
|
+
|
|
165
|
+
// A row that leaves the list — a chip filter, a collapsed section, a
|
|
166
|
+
// completed delete — cannot stay selected. Survivors keep their selection.
|
|
167
|
+
const { intersectWith } = selection;
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
intersectWith(orderedIds);
|
|
170
|
+
}, [intersectWith, orderedIds]);
|
|
171
|
+
|
|
172
|
+
// Real browser focus follows the cursor, so Tab, Shift+Tab and the focus ring
|
|
173
|
+
// agree with what the list highlights.
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
const pending = pendingDomFocusRef.current;
|
|
176
|
+
if (pending === null) return;
|
|
177
|
+
pendingDomFocusRef.current = null;
|
|
178
|
+
containerRef.current
|
|
179
|
+
?.querySelector<HTMLElement>(`[data-message-id="${pending}"]`)
|
|
180
|
+
?.focus();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const handleFocusRow = useCallback(
|
|
184
|
+
(messageId: string) => {
|
|
185
|
+
cursorMovedByPointerRef.current = true;
|
|
186
|
+
setFocusedMessageId(messageId);
|
|
187
|
+
},
|
|
188
|
+
[cursorMovedByPointerRef, setFocusedMessageId],
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const handleLongPress = useCallback(
|
|
192
|
+
(messageId: string) => {
|
|
193
|
+
if (!isDesktop) select(messageId);
|
|
194
|
+
},
|
|
195
|
+
[isDesktop, select],
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const openFocused = useCallback(() => {
|
|
199
|
+
if (focusedMessageId) onOpen(focusedMessageId);
|
|
200
|
+
}, [focusedMessageId, onOpen]);
|
|
201
|
+
|
|
202
|
+
// Pending move-to-Trash, awaiting confirmation. The ids are snapshotted at
|
|
203
|
+
// request time so a selection change behind the dialog cannot retarget it —
|
|
204
|
+
// the same contract the mailbox list's delete has.
|
|
205
|
+
const [pendingDelete, setPendingDelete] = useState<string[] | null>(null);
|
|
206
|
+
|
|
207
|
+
const requestDeleteIds = useCallback(
|
|
208
|
+
(ids: string[]): boolean => {
|
|
209
|
+
if (!onDeleteMessages || ids.length === 0) return false;
|
|
210
|
+
setPendingDelete(ids);
|
|
211
|
+
return true;
|
|
212
|
+
},
|
|
213
|
+
[onDeleteMessages],
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const requestDeleteSelection = useCallback(() => {
|
|
217
|
+
requestDeleteIds(Array.from(selectedIds));
|
|
218
|
+
}, [requestDeleteIds, selectedIds]);
|
|
219
|
+
|
|
220
|
+
const requestDelete = useCallback((): boolean => {
|
|
221
|
+
// The confirmation is already asking about a delete: the keypress belongs
|
|
222
|
+
// to it. Claiming the press here is what stops a second Delete from
|
|
223
|
+
// reaching an unconfirmed delete.
|
|
224
|
+
if (pendingDelete !== null) return true;
|
|
225
|
+
if (selectedCount > 0) return requestDeleteIds(Array.from(selectedIds));
|
|
226
|
+
if (focusedMessageId) return requestDeleteIds([focusedMessageId]);
|
|
227
|
+
return false;
|
|
228
|
+
}, [
|
|
229
|
+
pendingDelete,
|
|
230
|
+
selectedCount,
|
|
231
|
+
selectedIds,
|
|
232
|
+
focusedMessageId,
|
|
233
|
+
requestDeleteIds,
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
const confirmDelete = useCallback(() => {
|
|
237
|
+
if (pendingDelete === null) return;
|
|
238
|
+
onDeleteMessages?.(pendingDelete);
|
|
239
|
+
setPendingDelete(null);
|
|
240
|
+
exitSelection();
|
|
241
|
+
}, [pendingDelete, onDeleteMessages, exitSelection]);
|
|
242
|
+
|
|
243
|
+
const cancelDelete = useCallback(() => setPendingDelete(null), []);
|
|
244
|
+
|
|
245
|
+
const clearSelectionCommand = useCallback((): boolean => {
|
|
246
|
+
if (selectedCount === 0) return false;
|
|
247
|
+
exitSelection();
|
|
248
|
+
return true;
|
|
249
|
+
}, [selectedCount, exitSelection]);
|
|
250
|
+
|
|
251
|
+
const hasList = orderedIds.length > 0;
|
|
252
|
+
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
if (!commandsRef) return;
|
|
255
|
+
if (!hasList) {
|
|
256
|
+
commandsRef.current = null;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
commandsRef.current = {
|
|
260
|
+
focusNext: cursor.focusNext,
|
|
261
|
+
focusPrevious: cursor.focusPrevious,
|
|
262
|
+
focusFirst: cursor.focusFirst,
|
|
263
|
+
focusLast: cursor.focusLast,
|
|
264
|
+
openFocused,
|
|
265
|
+
toggleSelect: cursor.toggleFocusedSelection,
|
|
266
|
+
extendSelectDown: cursor.extendRangeDown,
|
|
267
|
+
extendSelectUp: cursor.extendRangeUp,
|
|
268
|
+
selectAll: cursor.selectAllLoaded,
|
|
269
|
+
clearSelection: clearSelectionCommand,
|
|
270
|
+
requestDelete,
|
|
271
|
+
// The brief and Flagged have no density switch; the key stays inert here
|
|
272
|
+
// rather than moving a control these views do not offer.
|
|
273
|
+
toggleDensity: () => undefined,
|
|
274
|
+
};
|
|
275
|
+
return () => {
|
|
276
|
+
commandsRef.current = null;
|
|
277
|
+
};
|
|
278
|
+
}, [
|
|
279
|
+
commandsRef,
|
|
280
|
+
hasList,
|
|
281
|
+
cursor.focusNext,
|
|
282
|
+
cursor.focusPrevious,
|
|
283
|
+
cursor.focusFirst,
|
|
284
|
+
cursor.focusLast,
|
|
285
|
+
cursor.toggleFocusedSelection,
|
|
286
|
+
cursor.extendRangeDown,
|
|
287
|
+
cursor.extendRangeUp,
|
|
288
|
+
cursor.selectAllLoaded,
|
|
289
|
+
openFocused,
|
|
290
|
+
clearSelectionCommand,
|
|
291
|
+
requestDelete,
|
|
292
|
+
]);
|
|
293
|
+
|
|
294
|
+
const selectedIdList = useMemo(() => Array.from(selectedIds), [selectedIds]);
|
|
295
|
+
const confirmOpen = pendingDelete !== null;
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
onTriageContextChange?.({
|
|
298
|
+
focusedMessageId,
|
|
299
|
+
selectedIds: selectedIdList,
|
|
300
|
+
orderedIds,
|
|
301
|
+
hasList,
|
|
302
|
+
// The dialog owns the keyboard while it is up, so the triage layer
|
|
303
|
+
// suspends rather than acting behind it.
|
|
304
|
+
blocksKeyboard: confirmOpen,
|
|
305
|
+
});
|
|
306
|
+
}, [
|
|
307
|
+
onTriageContextChange,
|
|
308
|
+
focusedMessageId,
|
|
309
|
+
selectedIdList,
|
|
310
|
+
orderedIds,
|
|
311
|
+
hasList,
|
|
312
|
+
confirmOpen,
|
|
313
|
+
]);
|
|
314
|
+
|
|
315
|
+
const tabStop = tabStopId(orderedIds, focusedMessageId);
|
|
316
|
+
|
|
317
|
+
const value = useMemo<ThreadListInteractionValue>(
|
|
318
|
+
() => ({
|
|
319
|
+
selectedIds,
|
|
320
|
+
selectedCount,
|
|
321
|
+
exitSelection,
|
|
322
|
+
requestDeleteSelection,
|
|
323
|
+
rowInteraction: (messageId: string) => ({
|
|
324
|
+
focused: messageId === focusedMessageId,
|
|
325
|
+
isTabStop: messageId === tabStop,
|
|
326
|
+
isDesktop,
|
|
327
|
+
onFocusRow: handleFocusRow,
|
|
328
|
+
selection: {
|
|
329
|
+
isChecked: isSelected(messageId),
|
|
330
|
+
onToggleCheck: toggle,
|
|
331
|
+
onRowSelect: handleRowSelect,
|
|
332
|
+
isMultiSelectMode,
|
|
333
|
+
onLongPress: handleLongPress,
|
|
334
|
+
},
|
|
335
|
+
}),
|
|
336
|
+
}),
|
|
337
|
+
[
|
|
338
|
+
selectedIds,
|
|
339
|
+
selectedCount,
|
|
340
|
+
exitSelection,
|
|
341
|
+
requestDeleteSelection,
|
|
342
|
+
focusedMessageId,
|
|
343
|
+
tabStop,
|
|
344
|
+
isDesktop,
|
|
345
|
+
handleFocusRow,
|
|
346
|
+
isSelected,
|
|
347
|
+
toggle,
|
|
348
|
+
handleRowSelect,
|
|
349
|
+
isMultiSelectMode,
|
|
350
|
+
handleLongPress,
|
|
351
|
+
],
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
<ThreadListInteractionCtx.Provider value={value}>
|
|
356
|
+
{/* `display: contents` so reading the rendered rows costs the layout
|
|
357
|
+
nothing — the children lay out against their real parent. */}
|
|
358
|
+
<div ref={containerRef} className="contents">
|
|
359
|
+
{children}
|
|
360
|
+
</div>
|
|
361
|
+
<ConfirmDialog
|
|
362
|
+
isOpen={confirmOpen}
|
|
363
|
+
title={formatDeleteToTrashTitle(pendingDelete?.length ?? 0)}
|
|
364
|
+
description="You can restore them from Trash later."
|
|
365
|
+
confirmLabel="Move to Trash"
|
|
366
|
+
destructive
|
|
367
|
+
isBusy={isDeleting}
|
|
368
|
+
onConfirm={confirmDelete}
|
|
369
|
+
onCancel={cancelDelete}
|
|
370
|
+
/>
|
|
371
|
+
</ThreadListInteractionCtx.Provider>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
interface ThreadListSelectionBarProps {
|
|
376
|
+
onMarkAsRead?: (messageIds: string[]) => void;
|
|
377
|
+
isDeleting?: boolean;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Selection bar for a list inside `ThreadListInteraction`.
|
|
382
|
+
*
|
|
383
|
+
* Move is not offered here: the brief and Flagged span accounts and mailboxes,
|
|
384
|
+
* and a move picker needs one account and one source folder to be honest about
|
|
385
|
+
* where the messages go. Delete and mark-read carry no such scope.
|
|
386
|
+
*/
|
|
387
|
+
export function ThreadListSelectionBar({
|
|
388
|
+
onMarkAsRead,
|
|
389
|
+
isDeleting,
|
|
390
|
+
}: ThreadListSelectionBarProps) {
|
|
391
|
+
const { selectedIds, selectedCount, exitSelection, requestDeleteSelection } =
|
|
392
|
+
useThreadListSelection();
|
|
393
|
+
|
|
394
|
+
const handleMarkAsRead = useCallback(() => {
|
|
395
|
+
onMarkAsRead?.(Array.from(selectedIds));
|
|
396
|
+
exitSelection();
|
|
397
|
+
}, [onMarkAsRead, selectedIds, exitSelection]);
|
|
398
|
+
|
|
399
|
+
return (
|
|
400
|
+
<SelectionToolbar
|
|
401
|
+
selectedCount={selectedCount}
|
|
402
|
+
onDelete={requestDeleteSelection}
|
|
403
|
+
onClearSelection={exitSelection}
|
|
404
|
+
onMarkAsRead={onMarkAsRead ? handleMarkAsRead : undefined}
|
|
405
|
+
isDeleting={isDeleting}
|
|
406
|
+
/>
|
|
407
|
+
);
|
|
408
|
+
}
|
|
@@ -8,6 +8,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
|
8
8
|
import { useCallback } from "react";
|
|
9
9
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
10
10
|
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
11
|
+
import { resolveMailboxesForMessages } from "@/hooks/useMarkAsRead";
|
|
11
12
|
import {
|
|
12
13
|
cancelThreadListQueries,
|
|
13
14
|
invalidateThreadListQueries,
|
|
@@ -22,6 +23,13 @@ interface UseDeleteMessagesOptions {
|
|
|
22
23
|
mailboxId: string;
|
|
23
24
|
threadId?: string;
|
|
24
25
|
accountId?: string;
|
|
26
|
+
/**
|
|
27
|
+
* The threads the ids may come from, when the caller has them. A selection in
|
|
28
|
+
* the brief or Flagged spans mailboxes and accounts, so the listings to patch
|
|
29
|
+
* are the ones each message actually lives in — `mailboxId` alone would leave
|
|
30
|
+
* every other mailbox's cached list holding a deleted row.
|
|
31
|
+
*/
|
|
32
|
+
messages?: RemitImapThreadMessageResponse[];
|
|
25
33
|
/**
|
|
26
34
|
* Called once the optimistic removal has been applied. Use this to
|
|
27
35
|
* navigate away from a now-empty thread (e.g. clear `selectedMessageId`)
|
|
@@ -77,6 +85,7 @@ export const useDeleteMessages = ({
|
|
|
77
85
|
mailboxId,
|
|
78
86
|
threadId,
|
|
79
87
|
accountId,
|
|
88
|
+
messages,
|
|
80
89
|
onAfterOptimisticRemove,
|
|
81
90
|
}: UseDeleteMessagesOptions) => {
|
|
82
91
|
const queryClient = useQueryClient();
|
|
@@ -92,10 +101,13 @@ export const useDeleteMessages = ({
|
|
|
92
101
|
path: { threadId },
|
|
93
102
|
})
|
|
94
103
|
: [];
|
|
95
|
-
//
|
|
96
|
-
// that backs the daily brief — deleting from the
|
|
97
|
-
// row there too, not only from the per-mailbox
|
|
98
|
-
|
|
104
|
+
// Every mailbox the deleted messages live in, plus the unified
|
|
105
|
+
// cross-account listing that backs the daily brief — deleting from the
|
|
106
|
+
// brief has to remove the row there too, not only from the per-mailbox
|
|
107
|
+
// lists (#140, part of #149).
|
|
108
|
+
const listPrefixes = threadListCacheKeys(
|
|
109
|
+
resolveMailboxesForMessages(messageIds, messages ?? [], mailboxId),
|
|
110
|
+
);
|
|
99
111
|
|
|
100
112
|
await Promise.all([
|
|
101
113
|
queryClient.cancelQueries({ queryKey: threadMessagesPrefix }),
|