@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.
@@ -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
- // The browsed mailbox's lists plus the unified cross-account listing
96
- // that backs the daily brief — deleting from the brief has to remove the
97
- // row there too, not only from the per-mailbox lists (#140, part of #149).
98
- const listPrefixes = threadListCacheKeys([mailboxId]);
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 }),