@remit/web-client 0.0.25 → 0.0.26
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/MailboxPane.tsx +12 -0
- package/src/components/mail/MessageList.tsx +339 -33
- package/src/hooks/useEscalatedDelete.ts +244 -0
- package/src/lib/bulk-delete.test.ts +456 -0
- package/src/lib/bulk-delete.ts +251 -0
- package/src/lib/escalation-label.test.ts +49 -0
- package/src/lib/escalation-label.ts +32 -0
- package/src/lib/format.test.ts +7 -0
- package/src/lib/format.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.26",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -81,6 +81,7 @@ import {
|
|
|
81
81
|
dropDeletedThreads,
|
|
82
82
|
useDeleteMessages,
|
|
83
83
|
} from "@/hooks/useDeleteMessages";
|
|
84
|
+
import type { EscalationSearchQuery } from "@/hooks/useEscalatedDelete";
|
|
84
85
|
import { useIntelligenceData } from "@/hooks/useIntelligenceData";
|
|
85
86
|
import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
|
|
86
87
|
import { useLayoutTier } from "@/hooks/useLayoutTier";
|
|
@@ -183,6 +184,14 @@ interface MailboxPaneContextValue {
|
|
|
183
184
|
onClearFilters: () => void;
|
|
184
185
|
intelligenceOpen: boolean;
|
|
185
186
|
onToggleIntelligence: () => void;
|
|
187
|
+
/**
|
|
188
|
+
* The active search predicate — undefined when not searching. Threaded
|
|
189
|
+
* down so the mobile list can re-issue the identical filter while paging
|
|
190
|
+
* past what's loaded (escalated select-all, issue #92); the display string
|
|
191
|
+
* on `searchQuery`/`useMailContext` only carries what's shown in the
|
|
192
|
+
* header, not enough to reproduce the query server-side.
|
|
193
|
+
*/
|
|
194
|
+
searchPredicate: EscalationSearchQuery | undefined;
|
|
186
195
|
// List actions
|
|
187
196
|
onDeleteMessages: (ids: string[]) => void;
|
|
188
197
|
onMoveMessages: (ids: string[], dest: string) => void;
|
|
@@ -866,6 +875,7 @@ function MailboxPaneProvider({
|
|
|
866
875
|
onClearFilters,
|
|
867
876
|
intelligenceOpen,
|
|
868
877
|
onToggleIntelligence,
|
|
878
|
+
searchPredicate: hasSearchQuery ? searchThreadsQuery : undefined,
|
|
869
879
|
onDeleteMessages: handleDeleteMessages,
|
|
870
880
|
onMoveMessages: handleMoveMessages,
|
|
871
881
|
isDeleting,
|
|
@@ -935,6 +945,7 @@ function MailboxList() {
|
|
|
935
945
|
onSelectFilterCategory,
|
|
936
946
|
onToggleFilterAttribute,
|
|
937
947
|
onClearFilters,
|
|
948
|
+
searchPredicate,
|
|
938
949
|
} = useMailboxPane();
|
|
939
950
|
const { searchQuery, searchInput, accounts } = useMailContext();
|
|
940
951
|
const tier = useLayoutTier();
|
|
@@ -1013,6 +1024,7 @@ function MailboxList() {
|
|
|
1013
1024
|
error={error}
|
|
1014
1025
|
onRetry={onRetry}
|
|
1015
1026
|
searchQuery={searchQuery}
|
|
1027
|
+
searchPredicate={searchPredicate}
|
|
1016
1028
|
onDeleteMessages={onDeleteMessages}
|
|
1017
1029
|
onMoveMessages={onMoveMessages}
|
|
1018
1030
|
isDeleting={isDeleting}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Banner,
|
|
4
|
+
type Density,
|
|
5
|
+
MessageListPane,
|
|
6
|
+
SelectionTopBar,
|
|
7
|
+
} from "@remit/ui";
|
|
3
8
|
import { useNavigate } from "@tanstack/react-router";
|
|
4
9
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
5
10
|
import { Search, Sparkles } from "lucide-react";
|
|
@@ -7,6 +12,10 @@ import type { RefObject } from "react";
|
|
|
7
12
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
8
13
|
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
|
9
14
|
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
15
|
+
import {
|
|
16
|
+
type EscalationSearchQuery,
|
|
17
|
+
useEscalatedDelete,
|
|
18
|
+
} from "@/hooks/useEscalatedDelete";
|
|
10
19
|
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
11
20
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
12
21
|
import {
|
|
@@ -15,8 +24,17 @@ import {
|
|
|
15
24
|
useSelection,
|
|
16
25
|
} from "@/hooks/useSelection";
|
|
17
26
|
import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
|
|
18
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
BULK_DELETE_CHUNK_SIZE,
|
|
29
|
+
resolveSelectionAfterDelete,
|
|
30
|
+
} from "@/lib/bulk-delete";
|
|
31
|
+
import {
|
|
32
|
+
escalatedStatusLabel,
|
|
33
|
+
escalationActionLabel,
|
|
34
|
+
} from "@/lib/escalation-label";
|
|
35
|
+
import { formatDeleteToTrashTitle, formatNumber } from "@/lib/format";
|
|
19
36
|
import { tabStopId } from "@/lib/list-focus";
|
|
37
|
+
import { cn } from "@/lib/utils";
|
|
20
38
|
import { MoveToTrigger } from "./MoveToTrigger";
|
|
21
39
|
import { OrganizeDialog } from "./organize/OrganizeDialog";
|
|
22
40
|
import { SelectionToolbar } from "./SelectionToolbar";
|
|
@@ -58,6 +76,13 @@ interface MessageListProps {
|
|
|
58
76
|
error?: unknown;
|
|
59
77
|
onRetry?: () => void;
|
|
60
78
|
searchQuery?: string;
|
|
79
|
+
/**
|
|
80
|
+
* The active search predicate (undefined when not searching). Re-issued
|
|
81
|
+
* with fresh continuation tokens to page past what's loaded — the
|
|
82
|
+
* escalated select-all flow (issue #92) — since `searchQuery` above is
|
|
83
|
+
* only the display string.
|
|
84
|
+
*/
|
|
85
|
+
searchPredicate?: EscalationSearchQuery;
|
|
61
86
|
onDeleteMessages?: (messageIds: string[]) => void;
|
|
62
87
|
onMarkAsRead?: (messageIds: string[]) => void;
|
|
63
88
|
onMoveMessages?: (messageIds: string[], destinationMailboxId: string) => void;
|
|
@@ -156,6 +181,7 @@ export const MessageList = ({
|
|
|
156
181
|
error,
|
|
157
182
|
onRetry,
|
|
158
183
|
searchQuery,
|
|
184
|
+
searchPredicate,
|
|
159
185
|
onDeleteMessages,
|
|
160
186
|
onMarkAsRead,
|
|
161
187
|
onMoveMessages,
|
|
@@ -176,6 +202,7 @@ export const MessageList = ({
|
|
|
176
202
|
const isDesktop = useIsDesktop();
|
|
177
203
|
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
|
|
178
204
|
const [organizeOpen, setOrganizeOpen] = useState(false);
|
|
205
|
+
const isSearching = !!searchQuery?.trim();
|
|
179
206
|
|
|
180
207
|
// Roving focus cursor (#429): the keyboard "where am I" pointer, distinct
|
|
181
208
|
// from the open thread (`selectedMessageId` in the URL). j/k move this
|
|
@@ -216,15 +243,48 @@ export const MessageList = ({
|
|
|
216
243
|
selectRange,
|
|
217
244
|
setAnchor,
|
|
218
245
|
selectAll,
|
|
246
|
+
toggleAll,
|
|
219
247
|
} = useSelection();
|
|
220
248
|
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
// dialog can't retarget the delete
|
|
224
|
-
|
|
249
|
+
// Pending delete, awaiting confirmation. `null` means the dialog is closed.
|
|
250
|
+
// `source: "ids"` snapshots the concrete ids at request time so a selection
|
|
251
|
+
// change behind the dialog can't retarget the delete — every keyboard/desktop
|
|
252
|
+
// entry point, and any bounded mobile delete, uses this. `source: "predicate"`
|
|
253
|
+
// is mobile-only: an escalated selection has no materialized id list to
|
|
254
|
+
// snapshot (D2, issue #92) — only the count it was confirmed against.
|
|
255
|
+
type PendingDelete =
|
|
256
|
+
| { source: "ids"; ids: string[] }
|
|
257
|
+
| { source: "predicate"; total: number };
|
|
258
|
+
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(
|
|
225
259
|
null,
|
|
226
260
|
);
|
|
227
261
|
|
|
262
|
+
// Search-scoped escalated selection + chunked bulk delete (issue #92):
|
|
263
|
+
// mobile only, and only while search has more matches than are loaded.
|
|
264
|
+
// `orderedIds` below feeds `allLoadedSelected`; declared after this hook so
|
|
265
|
+
// its callback deps stay simple — see the `orderedIds`/`handleRowSelect`
|
|
266
|
+
// block.
|
|
267
|
+
const escalationEnabled = !isDesktop && isSearching && !!searchPredicate;
|
|
268
|
+
const predicateKey = `${mailboxId}|${JSON.stringify(searchPredicate ?? {})}`;
|
|
269
|
+
const escalation = useEscalatedDelete({
|
|
270
|
+
mailboxId,
|
|
271
|
+
accountId,
|
|
272
|
+
enabled: escalationEnabled,
|
|
273
|
+
predicateKey,
|
|
274
|
+
searchQuery: searchPredicate ?? {},
|
|
275
|
+
});
|
|
276
|
+
// Non-null exactly once a bounded/escalated run just ended with some ids
|
|
277
|
+
// still not confirmed deleted: the count that DID succeed in that run, so
|
|
278
|
+
// the partial-failure notice can say "N moved to Trash" alongside "Retry".
|
|
279
|
+
// `selectedIds` (materialized to the failed ids) is the source of truth for
|
|
280
|
+
// how many are left; this only supplies the other half of that sentence.
|
|
281
|
+
const [lastRunSucceeded, setLastRunSucceeded] = useState<number | null>(null);
|
|
282
|
+
// Transient, manually-dismissed success banner shown in place of the
|
|
283
|
+
// selection bar once a chunked/escalated delete finishes cleanly — see
|
|
284
|
+
// `processDeleteOutcome`. Honest about IMAP's async catch-up rather than
|
|
285
|
+
// claiming a finality the bulk endpoint's response doesn't have.
|
|
286
|
+
const [completionBanner, setCompletionBanner] = useState<string | null>(null);
|
|
287
|
+
|
|
228
288
|
// Set when a keyboard command moves the roving cursor. Real DOM focus then
|
|
229
289
|
// follows the cursor onto the row once the virtualizer has rendered it, so
|
|
230
290
|
// the browser's own focus — and therefore Tab, Shift+Tab and the focus ring
|
|
@@ -253,7 +313,7 @@ export const MessageList = ({
|
|
|
253
313
|
// the route fall through to its own unconfirmed delete on a second Delete
|
|
254
314
|
// press. The route suspends the whole keyboard layer for the dialog instead.
|
|
255
315
|
const commandsAvailable = !isLoading && threads.length > 0;
|
|
256
|
-
const confirmOpen =
|
|
316
|
+
const confirmOpen = pendingDelete !== null;
|
|
257
317
|
|
|
258
318
|
// Auto-exit multi-select when selection becomes empty
|
|
259
319
|
useEffect(() => {
|
|
@@ -262,6 +322,34 @@ export const MessageList = ({
|
|
|
262
322
|
}
|
|
263
323
|
}, [isMultiSelectMode, selectedCount]);
|
|
264
324
|
|
|
325
|
+
// Single choke point for what selection looks like once a chunked/escalated
|
|
326
|
+
// run ends, for any reason (issue #92 requirement 10). A clean run with
|
|
327
|
+
// nothing left over exits selection mode and hands off to a transient
|
|
328
|
+
// completion banner instead of silently claiming a finality the bulk
|
|
329
|
+
// endpoint's response doesn't have (IMAP applies the move asynchronously).
|
|
330
|
+
// Anything left over becomes the new bounded selection — precisely what
|
|
331
|
+
// Retry resends, per `resolveSelectionAfterDelete`.
|
|
332
|
+
const processDeleteOutcome = useCallback(
|
|
333
|
+
(outcome: Parameters<typeof resolveSelectionAfterDelete>[0]) => {
|
|
334
|
+
const { exit, retryIds } = resolveSelectionAfterDelete(outcome);
|
|
335
|
+
if (exit) {
|
|
336
|
+
setCompletionBanner(
|
|
337
|
+
`${formatNumber(outcome.done)} moved to Trash. Your mail server is still catching up.`,
|
|
338
|
+
);
|
|
339
|
+
setLastRunSucceeded(null);
|
|
340
|
+
clearSelection();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (retryIds.length > 0) {
|
|
344
|
+
setLastRunSucceeded(outcome.done);
|
|
345
|
+
selectAll(retryIds);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
setLastRunSucceeded(null);
|
|
349
|
+
},
|
|
350
|
+
[clearSelection, selectAll],
|
|
351
|
+
);
|
|
352
|
+
|
|
265
353
|
const virtualizer = useVirtualizer({
|
|
266
354
|
count: threads.length,
|
|
267
355
|
getScrollElement: () => parentRef.current,
|
|
@@ -359,11 +447,20 @@ export const MessageList = ({
|
|
|
359
447
|
(ids: string[]) => {
|
|
360
448
|
if (!onDeleteMessages || ids.length === 0) return;
|
|
361
449
|
focusBeforeConfirmRef.current = focusedMessageId ?? null;
|
|
362
|
-
|
|
450
|
+
setPendingDelete({ source: "ids", ids });
|
|
363
451
|
},
|
|
364
452
|
[onDeleteMessages, focusedMessageId],
|
|
365
453
|
);
|
|
366
454
|
|
|
455
|
+
// Mobile-only: open the delete confirmation for the escalated predicate.
|
|
456
|
+
// `escalation.phase` must already be "escalated" — the caller (the mobile
|
|
457
|
+
// bar's onDelete) only wires this up in that state.
|
|
458
|
+
const requestEscalatedDelete = useCallback(() => {
|
|
459
|
+
if (escalation.phase.kind !== "escalated") return;
|
|
460
|
+
focusBeforeConfirmRef.current = focusedMessageId ?? null;
|
|
461
|
+
setPendingDelete({ source: "predicate", total: escalation.phase.total });
|
|
462
|
+
}, [escalation.phase, focusedMessageId]);
|
|
463
|
+
|
|
367
464
|
// Keyboard shift-arrow range extend: move focus one row in `direction` and
|
|
368
465
|
// extend the selection range from the existing anchor to the new focus —
|
|
369
466
|
// the keyboard equivalent of shift-click. The anchor stays fixed across
|
|
@@ -402,7 +499,7 @@ export const MessageList = ({
|
|
|
402
499
|
// The confirmation is already asking about a delete: the keypress belongs
|
|
403
500
|
// to it, and answering it is the Confirm button's job. Claiming the press
|
|
404
501
|
// here is what stops a second Delete from reaching an unconfirmed delete.
|
|
405
|
-
if (
|
|
502
|
+
if (pendingDelete !== null) return true;
|
|
406
503
|
if (!onDeleteMessages) return false;
|
|
407
504
|
if (selectedCount > 0) {
|
|
408
505
|
requestDelete(Array.from(selectedIds));
|
|
@@ -414,7 +511,7 @@ export const MessageList = ({
|
|
|
414
511
|
}
|
|
415
512
|
return false;
|
|
416
513
|
}, [
|
|
417
|
-
|
|
514
|
+
pendingDelete,
|
|
418
515
|
onDeleteMessages,
|
|
419
516
|
selectedCount,
|
|
420
517
|
selectedIds,
|
|
@@ -440,14 +537,47 @@ export const MessageList = ({
|
|
|
440
537
|
}
|
|
441
538
|
}, [requestDelete, selectedCount, selectedIds]);
|
|
442
539
|
|
|
540
|
+
// Confirm handler for the escalated predicate, or a bounded selection past
|
|
541
|
+
// the 100-id bulk-call cap (>100 loaded rows selected — rare, but the write
|
|
542
|
+
// side would 400 on a single call past that). Neither case gets the
|
|
543
|
+
// cursor-repositioning treatment below: the run takes real time and the
|
|
544
|
+
// user's attention is on the progress bar, not the roving cursor, and
|
|
545
|
+
// rows update via cache invalidation once the run ends rather than an
|
|
546
|
+
// optimistic per-row removal.
|
|
547
|
+
const runChunkedConfirmDelete = useCallback(
|
|
548
|
+
async (ids: string[] | undefined) => {
|
|
549
|
+
setPendingDelete(null);
|
|
550
|
+
focusBeforeConfirmRef.current = null;
|
|
551
|
+
const outcome = await escalation.runDelete(ids);
|
|
552
|
+
processDeleteOutcome(outcome);
|
|
553
|
+
},
|
|
554
|
+
[escalation, processDeleteOutcome],
|
|
555
|
+
);
|
|
556
|
+
|
|
443
557
|
// Confirm handler: run the actual bulk delete, then clear selection and
|
|
444
558
|
// move focus to a sensible neighbor (the row after the first deleted one).
|
|
445
559
|
const handleConfirmDelete = useCallback(() => {
|
|
446
|
-
if (!
|
|
447
|
-
|
|
560
|
+
if (!pendingDelete) return;
|
|
561
|
+
|
|
562
|
+
if (pendingDelete.source === "predicate") {
|
|
563
|
+
void runChunkedConfirmDelete(undefined);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const { ids } = pendingDelete;
|
|
568
|
+
if (ids.length === 0) {
|
|
569
|
+
setPendingDelete(null);
|
|
448
570
|
return;
|
|
449
571
|
}
|
|
450
|
-
|
|
572
|
+
if (ids.length > BULK_DELETE_CHUNK_SIZE) {
|
|
573
|
+
// Selection stays put (still `ids`) for the duration of the run —
|
|
574
|
+
// `processDeleteOutcome` is the one place that clears it, on success,
|
|
575
|
+
// or replaces it with whatever's left to retry.
|
|
576
|
+
void runChunkedConfirmDelete(ids);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const deletedSet = new Set(ids);
|
|
451
581
|
const firstDeletedIndex = threads.findIndex((t) =>
|
|
452
582
|
deletedSet.has(t.messageId),
|
|
453
583
|
);
|
|
@@ -469,10 +599,10 @@ export const MessageList = ({
|
|
|
469
599
|
}
|
|
470
600
|
}
|
|
471
601
|
|
|
472
|
-
onDeleteMessages?.(
|
|
602
|
+
onDeleteMessages?.(ids);
|
|
473
603
|
clearSelection();
|
|
474
604
|
focusBeforeConfirmRef.current = null;
|
|
475
|
-
|
|
605
|
+
setPendingDelete(null);
|
|
476
606
|
|
|
477
607
|
if (nextFocus !== undefined) {
|
|
478
608
|
// Same hand-back as cancelling, aimed at the surviving neighbour
|
|
@@ -488,12 +618,13 @@ export const MessageList = ({
|
|
|
488
618
|
});
|
|
489
619
|
}
|
|
490
620
|
}, [
|
|
491
|
-
|
|
621
|
+
pendingDelete,
|
|
492
622
|
threads,
|
|
493
623
|
onDeleteMessages,
|
|
494
624
|
clearSelection,
|
|
495
625
|
navigate,
|
|
496
626
|
mailboxId,
|
|
627
|
+
runChunkedConfirmDelete,
|
|
497
628
|
]);
|
|
498
629
|
|
|
499
630
|
// Every way out of the confirmation that isn't the delete — Escape, Cancel,
|
|
@@ -503,7 +634,7 @@ export const MessageList = ({
|
|
|
503
634
|
const handleCancelDelete = useCallback(() => {
|
|
504
635
|
const restoreTo = focusBeforeConfirmRef.current;
|
|
505
636
|
focusBeforeConfirmRef.current = null;
|
|
506
|
-
|
|
637
|
+
setPendingDelete(null);
|
|
507
638
|
if (restoreTo === null) return;
|
|
508
639
|
pendingDomFocusRef.current = restoreTo;
|
|
509
640
|
cursorMovedByPointerRef.current = false;
|
|
@@ -583,6 +714,45 @@ export const MessageList = ({
|
|
|
583
714
|
clearSelection();
|
|
584
715
|
}, [clearSelection]);
|
|
585
716
|
|
|
717
|
+
// Mobile bar's Trash tap: an escalated selection has no materialized ids to
|
|
718
|
+
// hand `requestDelete`, so it opens the predicate confirmation instead.
|
|
719
|
+
const handleMobileDelete = useCallback(() => {
|
|
720
|
+
if (escalation.phase.kind === "escalated") {
|
|
721
|
+
requestEscalatedDelete();
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
handleDelete();
|
|
725
|
+
}, [escalation.phase, requestEscalatedDelete, handleDelete]);
|
|
726
|
+
|
|
727
|
+
// Mobile bar's X: means "stop what's happening" throughout, not just
|
|
728
|
+
// "cancel selection" (issue #92 — the review flagged the X reading as
|
|
729
|
+
// ambiguous once a delete is running). Counting and deleting both stop at
|
|
730
|
+
// the next page boundary; an escalated-but-idle selection drops back to
|
|
731
|
+
// bounded on the way out, same as tapping "Clear selection" first.
|
|
732
|
+
const handleMobileCancel = useCallback(() => {
|
|
733
|
+
if (escalation.isDeleting || escalation.phase.kind === "counting") {
|
|
734
|
+
escalation.stop();
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
if (escalation.phase.kind === "escalated") {
|
|
738
|
+
escalation.clear();
|
|
739
|
+
}
|
|
740
|
+
handleCancelMultiSelect();
|
|
741
|
+
}, [escalation, handleCancelMultiSelect]);
|
|
742
|
+
|
|
743
|
+
// The escalation notice's "Clear selection" action: drop back to the
|
|
744
|
+
// bounded (all-loaded) selection without touching selection mode itself.
|
|
745
|
+
const handleClearEscalation = useCallback(() => {
|
|
746
|
+
escalation.clear();
|
|
747
|
+
}, [escalation]);
|
|
748
|
+
|
|
749
|
+
// Partial-failure notice's Retry: resend exactly the ids that didn't
|
|
750
|
+
// confirm deleted last time (`selectedIds`, materialized there by
|
|
751
|
+
// `processDeleteOutcome`) — never the original selection.
|
|
752
|
+
const handleRetryFailed = useCallback(() => {
|
|
753
|
+
void runChunkedConfirmDelete(Array.from(selectedIds));
|
|
754
|
+
}, [runChunkedConfirmDelete, selectedIds]);
|
|
755
|
+
|
|
586
756
|
// Scroll the roving focus cursor into view as it moves (j/k). Falls back to
|
|
587
757
|
// the open thread when nothing is focused yet.
|
|
588
758
|
useEffect(() => {
|
|
@@ -659,8 +829,14 @@ export const MessageList = ({
|
|
|
659
829
|
[],
|
|
660
830
|
);
|
|
661
831
|
|
|
662
|
-
// Clear selection when threads change (e.g., after delete)
|
|
832
|
+
// Clear selection when threads change (e.g., after delete). Skipped while
|
|
833
|
+
// an escalated run is active: `selectedIds` there is a stale loaded-rows
|
|
834
|
+
// snapshot from the moment escalation started (the real selection is the
|
|
835
|
+
// predicate, D2), not something a background refetch reshuffling `threads`
|
|
836
|
+
// should be allowed to blow away out from under a count or a delete in
|
|
837
|
+
// progress — that would silently exit selection mode mid-run.
|
|
663
838
|
useEffect(() => {
|
|
839
|
+
if (escalation.phase.kind !== "idle" || escalation.isDeleting) return;
|
|
664
840
|
const threadIds = new Set(threads.map((t) => t.messageId));
|
|
665
841
|
const hasOrphanedSelection = Array.from(selectedIds).some(
|
|
666
842
|
(id) => !threadIds.has(id),
|
|
@@ -668,7 +844,13 @@ export const MessageList = ({
|
|
|
668
844
|
if (hasOrphanedSelection) {
|
|
669
845
|
clearSelection();
|
|
670
846
|
}
|
|
671
|
-
}, [
|
|
847
|
+
}, [
|
|
848
|
+
threads,
|
|
849
|
+
selectedIds,
|
|
850
|
+
clearSelection,
|
|
851
|
+
escalation.phase,
|
|
852
|
+
escalation.isDeleting,
|
|
853
|
+
]);
|
|
672
854
|
|
|
673
855
|
// Load more when scrolling near the bottom
|
|
674
856
|
useEffect(() => {
|
|
@@ -793,22 +975,124 @@ export const MessageList = ({
|
|
|
793
975
|
/>
|
|
794
976
|
) : undefined;
|
|
795
977
|
|
|
978
|
+
// Tier one of the two-tier select-all (issue #92, following Gmail web):
|
|
979
|
+
// every loaded row is checked. Computed against actual membership, not a
|
|
980
|
+
// count comparison, so a transient mismatch (mid-render, before the
|
|
981
|
+
// orphaned-selection effect settles) can't read as "all loaded" by
|
|
982
|
+
// coincidence.
|
|
983
|
+
const allLoadedSelected =
|
|
984
|
+
orderedIds.length > 0 && orderedIds.every((id) => selectedIds.has(id));
|
|
985
|
+
|
|
986
|
+
// Tier two: offered only once tier one is complete and search has more
|
|
987
|
+
// matches than are loaded — never a bare "Select all" (requirement 4).
|
|
988
|
+
const escalationAvailable =
|
|
989
|
+
escalationEnabled &&
|
|
990
|
+
hasMore &&
|
|
991
|
+
allLoadedSelected &&
|
|
992
|
+
!isDeleting &&
|
|
993
|
+
!isMoving &&
|
|
994
|
+
escalation.phase.kind === "idle" &&
|
|
995
|
+
!escalation.isDeleting;
|
|
996
|
+
|
|
997
|
+
const mobileIsBusy = isDeleting || isMoving || escalation.isDeleting;
|
|
998
|
+
const mobileCount =
|
|
999
|
+
escalation.phase.kind === "escalated"
|
|
1000
|
+
? escalation.phase.total
|
|
1001
|
+
: selectedCount;
|
|
1002
|
+
|
|
1003
|
+
const mobileStatusLabel = escalation.isDeleting
|
|
1004
|
+
? `Deleting ${formatNumber(escalation.deleteProgress?.done ?? 0)} of ${formatNumber(escalation.deleteProgress?.total ?? 0)}…`
|
|
1005
|
+
: escalation.phase.kind === "counting"
|
|
1006
|
+
? escalation.phase.countSoFar >= 5000
|
|
1007
|
+
? `Counting… ${formatNumber(escalation.phase.countSoFar)} so far. This is a big result set.`
|
|
1008
|
+
: `Counting… ${formatNumber(escalation.phase.countSoFar)} so far`
|
|
1009
|
+
: escalation.phase.kind === "escalated"
|
|
1010
|
+
? escalatedStatusLabel(searchPredicate ?? {}, escalation.phase.total)
|
|
1011
|
+
: undefined;
|
|
1012
|
+
|
|
1013
|
+
const mobileSelectAll =
|
|
1014
|
+
escalation.phase.kind !== "escalated" && orderedIds.length > 0
|
|
1015
|
+
? {
|
|
1016
|
+
checked: allLoadedSelected,
|
|
1017
|
+
indeterminate: selectedCount > 0 && !allLoadedSelected,
|
|
1018
|
+
onChange: () => toggleAll(orderedIds),
|
|
1019
|
+
}
|
|
1020
|
+
: undefined;
|
|
1021
|
+
|
|
1022
|
+
// At most one notice at a time, ranked by how actionable it is: an
|
|
1023
|
+
// in-progress counting/escalated state and its own action always wins;
|
|
1024
|
+
// otherwise a fresh escalation offer; otherwise a just-finished partial
|
|
1025
|
+
// failure's Retry; otherwise the (rare) cross-account move hint.
|
|
1026
|
+
const mobileNotice =
|
|
1027
|
+
escalation.phase.kind === "counting"
|
|
1028
|
+
? {
|
|
1029
|
+
tone: "info" as const,
|
|
1030
|
+
text: "",
|
|
1031
|
+
action: { label: "Stop", onClick: escalation.stop },
|
|
1032
|
+
}
|
|
1033
|
+
: escalation.phase.kind === "escalated" && !escalation.isDeleting
|
|
1034
|
+
? {
|
|
1035
|
+
tone: "info" as const,
|
|
1036
|
+
text: "",
|
|
1037
|
+
action: {
|
|
1038
|
+
label: "Clear selection",
|
|
1039
|
+
onClick: handleClearEscalation,
|
|
1040
|
+
},
|
|
1041
|
+
}
|
|
1042
|
+
: escalationAvailable
|
|
1043
|
+
? {
|
|
1044
|
+
tone: "info" as const,
|
|
1045
|
+
text: "",
|
|
1046
|
+
action: {
|
|
1047
|
+
label: escalationActionLabel(searchPredicate ?? {}),
|
|
1048
|
+
onClick: escalation.escalate,
|
|
1049
|
+
},
|
|
1050
|
+
}
|
|
1051
|
+
: lastRunSucceeded !== null && selectedCount > 0
|
|
1052
|
+
? {
|
|
1053
|
+
tone: "danger" as const,
|
|
1054
|
+
text: `${formatNumber(lastRunSucceeded)} moved to Trash. ${formatNumber(selectedCount)} couldn't be deleted.`,
|
|
1055
|
+
action: {
|
|
1056
|
+
label: `Retry ${formatNumber(selectedCount)}`,
|
|
1057
|
+
onClick: handleRetryFailed,
|
|
1058
|
+
},
|
|
1059
|
+
}
|
|
1060
|
+
: moveDisabledHint
|
|
1061
|
+
? { tone: "warning" as const, text: moveDisabledHint }
|
|
1062
|
+
: undefined;
|
|
1063
|
+
|
|
796
1064
|
// Mobile multi-select bar replaces the pane header during selection mode.
|
|
797
1065
|
const mobileSelectionBar =
|
|
798
1066
|
isMultiSelectMode && !isDesktop ? (
|
|
799
1067
|
<SelectionTopBar
|
|
800
|
-
count={
|
|
801
|
-
onCancel={
|
|
802
|
-
onDelete={
|
|
803
|
-
onMarkRead={
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
1068
|
+
count={mobileCount}
|
|
1069
|
+
onCancel={handleMobileCancel}
|
|
1070
|
+
onDelete={handleMobileDelete}
|
|
1071
|
+
onMarkRead={
|
|
1072
|
+
onMarkAsRead && escalation.phase.kind === "idle"
|
|
1073
|
+
? handleMarkAsRead
|
|
1074
|
+
: undefined
|
|
1075
|
+
}
|
|
1076
|
+
isBusy={mobileIsBusy}
|
|
1077
|
+
isCounting={escalation.phase.kind === "counting"}
|
|
1078
|
+
statusLabel={mobileStatusLabel}
|
|
1079
|
+
selectAll={mobileSelectAll}
|
|
1080
|
+
progress={
|
|
1081
|
+
escalation.isDeleting && escalation.deleteProgress
|
|
1082
|
+
? {
|
|
1083
|
+
value: escalation.deleteProgress.done,
|
|
1084
|
+
max: escalation.deleteProgress.total,
|
|
1085
|
+
tone: "danger",
|
|
1086
|
+
}
|
|
808
1087
|
: undefined
|
|
809
1088
|
}
|
|
1089
|
+
notice={mobileNotice}
|
|
810
1090
|
moveSlot={
|
|
811
|
-
|
|
1091
|
+
escalation.phase.kind === "idle" &&
|
|
1092
|
+
!escalation.isDeleting &&
|
|
1093
|
+
onMoveMessages &&
|
|
1094
|
+
accountId &&
|
|
1095
|
+
mailboxId ? (
|
|
812
1096
|
<>
|
|
813
1097
|
{!moveDisabledHint && (
|
|
814
1098
|
<button
|
|
@@ -847,8 +1131,6 @@ export const MessageList = ({
|
|
|
847
1131
|
setFocusedMessageId(messageId);
|
|
848
1132
|
}, []);
|
|
849
1133
|
|
|
850
|
-
const isSearching = !!searchQuery?.trim();
|
|
851
|
-
|
|
852
1134
|
// The virtualized list body: rows + search header + load-more indicator.
|
|
853
1135
|
// Passed to MessageListPane as `listBody` so the kit provides the chrome
|
|
854
1136
|
// (pane header, loading / empty / error states, keyboard hints) while we
|
|
@@ -880,7 +1162,15 @@ export const MessageList = ({
|
|
|
880
1162
|
role="presentation"
|
|
881
1163
|
data-index={virtualRow.index}
|
|
882
1164
|
ref={virtualizer.measureElement}
|
|
883
|
-
className=
|
|
1165
|
+
className={cn(
|
|
1166
|
+
"absolute left-0 top-0 w-full border-b border-line",
|
|
1167
|
+
// A chunked/escalated delete keeps every targeted row
|
|
1168
|
+
// checked, dimmed and untappable for the whole run (issue
|
|
1169
|
+
// #92) — the number in the bar and the rows underneath
|
|
1170
|
+
// have to agree something is happening, and a row mid
|
|
1171
|
+
// -delete must not be openable.
|
|
1172
|
+
escalation.isDeleting && "pointer-events-none opacity-50",
|
|
1173
|
+
)}
|
|
884
1174
|
style={{ transform: `translateY(${virtualRow.start}px)` }}
|
|
885
1175
|
>
|
|
886
1176
|
<SwipeableMessageRow
|
|
@@ -914,8 +1204,24 @@ export const MessageList = ({
|
|
|
914
1204
|
</>
|
|
915
1205
|
);
|
|
916
1206
|
|
|
1207
|
+
const pendingDeleteCount = pendingDelete
|
|
1208
|
+
? pendingDelete.source === "ids"
|
|
1209
|
+
? pendingDelete.ids.length
|
|
1210
|
+
: pendingDelete.total
|
|
1211
|
+
: 0;
|
|
1212
|
+
|
|
917
1213
|
return (
|
|
918
1214
|
<>
|
|
1215
|
+
{completionBanner && !isDesktop && (
|
|
1216
|
+
<Banner
|
|
1217
|
+
tone="success"
|
|
1218
|
+
variant="soft"
|
|
1219
|
+
className="m-2 rounded-md"
|
|
1220
|
+
onDismiss={() => setCompletionBanner(null)}
|
|
1221
|
+
>
|
|
1222
|
+
{completionBanner}
|
|
1223
|
+
</Banner>
|
|
1224
|
+
)}
|
|
919
1225
|
<MessageListPane
|
|
920
1226
|
listTitle={listTitle}
|
|
921
1227
|
listMeta={listMeta}
|
|
@@ -942,8 +1248,8 @@ export const MessageList = ({
|
|
|
942
1248
|
listBody={listState === "ready" ? virtualBody : undefined}
|
|
943
1249
|
/>
|
|
944
1250
|
<ConfirmDialog
|
|
945
|
-
isOpen={
|
|
946
|
-
title={formatDeleteToTrashTitle(
|
|
1251
|
+
isOpen={pendingDelete !== null}
|
|
1252
|
+
title={formatDeleteToTrashTitle(pendingDeleteCount)}
|
|
947
1253
|
description="You can restore them from Trash later."
|
|
948
1254
|
confirmLabel="Move to Trash"
|
|
949
1255
|
destructive
|