@remit/web-client 0.0.93 → 0.0.95
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/mail/BriefPane.selection.test.ts +0 -1
- package/src/components/mail/BriefPane.tsx +7 -17
- package/src/components/mail/DailyBrief.selection.test.ts +21 -3
- package/src/components/mail/DailyBrief.tsx +37 -39
- package/src/components/mail/FlaggedList.tsx +55 -8
- package/src/components/mail/FlaggedPane.tsx +9 -23
- package/src/components/mail/MailListHeader.tsx +41 -39
- package/src/components/mail/MailboxPane.tsx +24 -11
- package/src/components/mail/MessageList.selection.test.ts +44 -30
- package/src/components/mail/MessageList.tsx +117 -339
- package/src/components/mail/SelectionWizardHost.tsx +265 -48
- package/src/components/mail/ThreadListInteraction.test.ts +74 -4
- package/src/components/mail/ThreadListInteraction.tsx +58 -50
- package/src/components/mail/make-filter-entry.test.ts +63 -0
- package/src/components/mail/selection-verbs.test.ts +278 -0
- package/src/components/ui/ConfirmDialog.stories.tsx +0 -15
- package/src/hooks/useEscalatedActions.ts +2 -2
- package/src/hooks/useMatchSample.ts +44 -3
- package/src/hooks/useRulePreview.ts +1 -2
- package/src/lib/bulk-action-copy.test.ts +0 -19
- package/src/lib/bulk-action-copy.ts +0 -8
- package/src/lib/bulk-actions.test.ts +0 -60
- package/src/lib/bulk-actions.ts +0 -26
- package/src/lib/format.test.ts +0 -27
- package/src/lib/format.ts +4 -14
- package/src/lib/list-header-chrome.ts +10 -0
- package/src/lib/organize/organize-model.ts +8 -2
- package/src/lib/organize/rule-model.ts +0 -4
- package/src/lib/wizard-history.test.ts +31 -0
- package/src/lib/wizard-history.ts +97 -9
- package/src/routes/mail.tsx +3 -1
- package/src/components/mail/organize/SearchFilterDialog.render.test.ts +0 -47
- package/src/components/mail/organize/SearchFilterDialog.tsx +0 -90
- package/src/components/mail/organize/SearchFilterEditor.render.test.ts +0 -260
- package/src/components/mail/organize/SearchFilterEditor.tsx +0 -140
- package/src/components/mail/organize/rule-editor-states.stories.tsx +0 -147
- package/src/components/mail/organize/rule-editor-states.tsx +0 -139
- package/src/hooks/useRuleEditorState.ts +0 -182
- package/src/hooks/useSearchFilterSeed.render.test.ts +0 -118
- package/src/hooks/useSearchFilterSeed.ts +0 -79
|
@@ -33,15 +33,11 @@ import {
|
|
|
33
33
|
import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
|
|
34
34
|
import {
|
|
35
35
|
bulkActionCompletionText,
|
|
36
|
-
bulkActionPartialText,
|
|
37
36
|
bulkActionProgressLabel,
|
|
38
37
|
bulkActionProgressTone,
|
|
39
38
|
} from "@/lib/bulk-action-copy";
|
|
40
39
|
import {
|
|
41
|
-
|
|
42
|
-
resolveSelectionAfterRun,
|
|
43
|
-
} from "@/lib/bulk-actions";
|
|
44
|
-
import {
|
|
40
|
+
describeSearchScope,
|
|
45
41
|
escalatedStatusLabel,
|
|
46
42
|
escalationActionLabel,
|
|
47
43
|
} from "@/lib/escalation-label";
|
|
@@ -57,10 +53,10 @@ import {
|
|
|
57
53
|
shouldExitSelectionOnNavigate,
|
|
58
54
|
} from "@/lib/selection-mode";
|
|
59
55
|
import { cn } from "@/lib/utils";
|
|
60
|
-
import {
|
|
56
|
+
import { useSelectionWizard, useWizardStepValue } from "@/lib/wizard-history";
|
|
61
57
|
import { LabelApplyTrigger } from "./LabelApplyTrigger";
|
|
62
|
-
import { MoveToTrigger } from "./MoveToTrigger";
|
|
63
58
|
import {
|
|
59
|
+
type EscalatedSelection,
|
|
64
60
|
SelectionWizardHost,
|
|
65
61
|
type WizardSelectionMessage,
|
|
66
62
|
} from "./SelectionWizardHost";
|
|
@@ -85,11 +81,15 @@ export interface MessageListCommands {
|
|
|
85
81
|
/** Returns true when there was a selection to clear — Esc consumes it. */
|
|
86
82
|
clearSelection: () => boolean;
|
|
87
83
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
84
|
+
* Runs a verb over the list's selection by opening the wizard on it, which is
|
|
85
|
+
* where every selection action is reviewed before it reaches the mail server
|
|
86
|
+
* (#477 1.4, #508). Returns false when the list did not take the press, so
|
|
87
|
+
* the pane's own verb can act on the focused row instead — a verb aimed at
|
|
88
|
+
* one row is not a bulk action and does not walk the wizard. Delete is the
|
|
89
|
+
* exception with nothing ticked: the list still claims it, to confirm the
|
|
90
|
+
* move to Trash and then place the cursor on a surviving row.
|
|
91
91
|
*/
|
|
92
|
-
|
|
92
|
+
requestVerb: (verb: Verb) => boolean;
|
|
93
93
|
toggleDensity: () => void;
|
|
94
94
|
}
|
|
95
95
|
|
|
@@ -110,7 +110,6 @@ interface MessageListProps {
|
|
|
110
110
|
*/
|
|
111
111
|
searchPredicate?: EscalationSearchQuery;
|
|
112
112
|
onDeleteMessages?: (messageIds: string[]) => void;
|
|
113
|
-
onMoveMessages?: (messageIds: string[], destinationMailboxId: string) => void;
|
|
114
113
|
isDeleting?: boolean;
|
|
115
114
|
isMoving?: boolean;
|
|
116
115
|
onLoadMore?: () => void;
|
|
@@ -158,9 +157,11 @@ interface MessageListProps {
|
|
|
158
157
|
*/
|
|
159
158
|
hasList: boolean;
|
|
160
159
|
/**
|
|
161
|
-
* Whether the list has a modal open that owns the keyboard
|
|
162
|
-
*
|
|
163
|
-
*
|
|
160
|
+
* Whether the list has a modal open that owns the keyboard — the delete
|
|
161
|
+
* confirmation, or the wizard. The route suspends the whole triage layer
|
|
162
|
+
* while it does, so no shortcut can act behind it: a second Delete press
|
|
163
|
+
* must not reach a delete, and none may start a second flow behind the
|
|
164
|
+
* screen already asking about one.
|
|
164
165
|
*/
|
|
165
166
|
blocksKeyboard: boolean;
|
|
166
167
|
}) => void;
|
|
@@ -181,11 +182,6 @@ const COMPACT_ITEM_HEIGHT = 32;
|
|
|
181
182
|
const OVERSCAN_COUNT = 5;
|
|
182
183
|
const DENSITY_STORAGE_KEY = "remit:list-density";
|
|
183
184
|
|
|
184
|
-
// Stable action values for the two bulk actions that carry no parameters, so
|
|
185
|
-
// the callbacks running them keep stable dependencies.
|
|
186
|
-
const DELETE_ACTION: EscalatedAction = { kind: "delete" };
|
|
187
|
-
const MARK_READ_ACTION: EscalatedAction = { kind: "markRead" };
|
|
188
|
-
|
|
189
185
|
const readStoredDensity = (): Density => {
|
|
190
186
|
try {
|
|
191
187
|
const stored = localStorage.getItem(DENSITY_STORAGE_KEY);
|
|
@@ -222,7 +218,6 @@ export const MessageList = ({
|
|
|
222
218
|
searchQuery,
|
|
223
219
|
searchPredicate,
|
|
224
220
|
onDeleteMessages,
|
|
225
|
-
onMoveMessages,
|
|
226
221
|
isDeleting = false,
|
|
227
222
|
isMoving = false,
|
|
228
223
|
onLoadMore,
|
|
@@ -240,12 +235,9 @@ export const MessageList = ({
|
|
|
240
235
|
const parentRef = useRef<HTMLDivElement>(null);
|
|
241
236
|
const navigate = useNavigate();
|
|
242
237
|
const isDesktop = useIsDesktop();
|
|
238
|
+
const wizard = useSelectionWizard();
|
|
239
|
+
const { verb: wizardVerb, start: startWizard, startFromSearch } = wizard;
|
|
243
240
|
const wizardStep = useWizardStepValue();
|
|
244
|
-
const openWizard = useOpenWizard();
|
|
245
|
-
// The verb the bar was pressed for. The wizard itself is open for as long as
|
|
246
|
-
// the URL holds a step, so a back that pops the last one closes it without
|
|
247
|
-
// anything here having to hear about it.
|
|
248
|
-
const [wizardVerb, setWizardVerb] = useState<Verb>("organize");
|
|
249
241
|
const isSearching = !!searchQuery?.trim();
|
|
250
242
|
const listHeaderChrome = useListHeaderChrome();
|
|
251
243
|
const { labels } = useLabelList(accountId);
|
|
@@ -322,18 +314,12 @@ export const MessageList = ({
|
|
|
322
314
|
// it back to the count, and across that render the two disagree.
|
|
323
315
|
const isMultiSelectMode = deriveIsMultiSelectMode(selectedCount, isDesktop);
|
|
324
316
|
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
type PendingDelete =
|
|
332
|
-
| { source: "ids"; ids: string[] }
|
|
333
|
-
| { source: "predicate"; total: number };
|
|
334
|
-
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(
|
|
335
|
-
null,
|
|
336
|
-
);
|
|
317
|
+
// The one delete that does not walk the wizard: the Delete key with nothing
|
|
318
|
+
// ticked, which acts on the row under the cursor. The ids are snapshotted at
|
|
319
|
+
// request time so a cursor move behind the dialog cannot retarget it. Every
|
|
320
|
+
// delete over a selection — ticked or escalated — goes through the wizard's
|
|
321
|
+
// review screen instead.
|
|
322
|
+
const [pendingDelete, setPendingDelete] = useState<string[] | null>(null);
|
|
337
323
|
|
|
338
324
|
// Search-scoped escalated selection + chunked bulk actions (issues #92, #212):
|
|
339
325
|
// available on both surfaces, and only while search has more matches than
|
|
@@ -361,20 +347,11 @@ export const MessageList = ({
|
|
|
361
347
|
clearSelection();
|
|
362
348
|
}, [clearSelection, clearEscalation, escalationPhaseKind]);
|
|
363
349
|
|
|
364
|
-
// Non-null exactly once a bounded/escalated run just ended with some ids
|
|
365
|
-
// the action never reached: which action ran and the count that DID succeed,
|
|
366
|
-
// so the partial-failure notice can say "N moved to Trash" alongside
|
|
367
|
-
// "Retry" and Retry can resend the same action. `selectedIds` (materialized
|
|
368
|
-
// to the failed ids) is the source of truth for how many are left; this only
|
|
369
|
-
// supplies the other half of that sentence.
|
|
370
|
-
const [lastRun, setLastRun] = useState<{
|
|
371
|
-
action: EscalatedAction;
|
|
372
|
-
succeeded: number;
|
|
373
|
-
} | null>(null);
|
|
374
350
|
// Transient, manually-dismissed success banner shown in place of the
|
|
375
|
-
// selection bar once a
|
|
376
|
-
//
|
|
377
|
-
// claiming a finality the bulk endpoint's
|
|
351
|
+
// selection bar once a single-row delete lands on mobile, where the list
|
|
352
|
+
// stays up and nothing else says it happened (#202). Honest about IMAP's
|
|
353
|
+
// async catch-up rather than claiming a finality the bulk endpoint's
|
|
354
|
+
// response doesn't have.
|
|
378
355
|
const [completionBanner, setCompletionBanner] = useState<string | null>(null);
|
|
379
356
|
|
|
380
357
|
// Set when a keyboard command moves the roving cursor. Real DOM focus then
|
|
@@ -407,37 +384,6 @@ export const MessageList = ({
|
|
|
407
384
|
const commandsAvailable = !isLoading && threads.length > 0;
|
|
408
385
|
const confirmOpen = pendingDelete !== null;
|
|
409
386
|
|
|
410
|
-
// Single choke point for what selection looks like once a chunked/escalated
|
|
411
|
-
// run ends, for any reason (issue #92 requirement 10). A clean run with
|
|
412
|
-
// nothing left over exits selection mode and hands off to a transient
|
|
413
|
-
// completion banner instead of silently claiming a finality the bulk
|
|
414
|
-
// endpoint's response doesn't have (IMAP applies the move asynchronously).
|
|
415
|
-
// Anything left over becomes the new bounded selection — precisely what
|
|
416
|
-
// Retry resends, per `resolveSelectionAfterRun`.
|
|
417
|
-
const processRunOutcome = useCallback(
|
|
418
|
-
(
|
|
419
|
-
action: EscalatedAction,
|
|
420
|
-
outcome: Parameters<typeof resolveSelectionAfterRun>[0],
|
|
421
|
-
) => {
|
|
422
|
-
const { exit, retryIds } = resolveSelectionAfterRun(outcome);
|
|
423
|
-
if (exit) {
|
|
424
|
-
setCompletionBanner(
|
|
425
|
-
bulkActionCompletionText(action.kind, outcome.done),
|
|
426
|
-
);
|
|
427
|
-
setLastRun(null);
|
|
428
|
-
exitSelection();
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
if (retryIds.length > 0) {
|
|
432
|
-
setLastRun({ action, succeeded: outcome.done });
|
|
433
|
-
selectAll(retryIds);
|
|
434
|
-
return;
|
|
435
|
-
}
|
|
436
|
-
setLastRun(null);
|
|
437
|
-
},
|
|
438
|
-
[exitSelection, selectAll],
|
|
439
|
-
);
|
|
440
|
-
|
|
441
387
|
const virtualizer = useVirtualizer({
|
|
442
388
|
count: threads.length,
|
|
443
389
|
getScrollElement: () => parentRef.current,
|
|
@@ -558,27 +504,18 @@ export const MessageList = ({
|
|
|
558
504
|
],
|
|
559
505
|
);
|
|
560
506
|
|
|
561
|
-
// Open the delete confirmation for
|
|
562
|
-
//
|
|
563
|
-
//
|
|
507
|
+
// Open the delete confirmation for the row under the cursor. A delete over a
|
|
508
|
+
// selection is a bulk action and walks the wizard instead, so this is the one
|
|
509
|
+
// delete the dialog still covers.
|
|
564
510
|
const requestDelete = useCallback(
|
|
565
511
|
(ids: string[]) => {
|
|
566
512
|
if (!onDeleteMessages || ids.length === 0) return;
|
|
567
513
|
focusBeforeConfirmRef.current = focusedMessageId ?? null;
|
|
568
|
-
setPendingDelete(
|
|
514
|
+
setPendingDelete(ids);
|
|
569
515
|
},
|
|
570
516
|
[onDeleteMessages, focusedMessageId],
|
|
571
517
|
);
|
|
572
518
|
|
|
573
|
-
// Open the delete confirmation for the escalated predicate. `escalation.phase`
|
|
574
|
-
// must already be "escalated" — the selection bar's onDelete (both surfaces)
|
|
575
|
-
// only wires this up in that state.
|
|
576
|
-
const requestEscalatedDelete = useCallback(() => {
|
|
577
|
-
if (escalation.phase.kind !== "escalated") return;
|
|
578
|
-
focusBeforeConfirmRef.current = focusedMessageId ?? null;
|
|
579
|
-
setPendingDelete({ source: "predicate", total: escalation.phase.total });
|
|
580
|
-
}, [escalation.phase, focusedMessageId]);
|
|
581
|
-
|
|
582
519
|
// Keyboard shift-arrow range extend: move focus one row in `direction` and
|
|
583
520
|
// extend the selection range from the existing anchor to the new focus —
|
|
584
521
|
// the keyboard equivalent of shift-click. The anchor stays fixed across
|
|
@@ -611,33 +548,41 @@ export const MessageList = ({
|
|
|
611
548
|
}
|
|
612
549
|
}, [orderedIds, selectAll]);
|
|
613
550
|
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
const
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
551
|
+
// A keyboard verb, routed the same way the bar routes its own. Over a
|
|
552
|
+
// selection every verb opens the wizard, so the keyboard cannot reach a bulk
|
|
553
|
+
// action the bar would have reviewed. Over a bare cursor only Delete is the
|
|
554
|
+
// list's, and it keeps the confirmation and the cursor hand-back.
|
|
555
|
+
const requestVerb = useCallback(
|
|
556
|
+
(verb: Verb): boolean => {
|
|
557
|
+
// The confirmation is already asking about a delete: the keypress belongs
|
|
558
|
+
// to it, and answering it is the Confirm button's job. Claiming the press
|
|
559
|
+
// here is what stops a second Delete from reaching an unconfirmed delete.
|
|
560
|
+
if (pendingDelete !== null) return true;
|
|
561
|
+
if (hasSelection) {
|
|
562
|
+
// Every verb on the bar opens the wizard (#477 1.4), whatever the
|
|
563
|
+
// selection is: the ticked rows, a selection spanning accounts — the
|
|
564
|
+
// wizard is where that restriction is stated, on the step that needs
|
|
565
|
+
// one account (#477 5.5) — or the predicate the list escalated to,
|
|
566
|
+
// which the wizard names on its match step and counts on its review
|
|
567
|
+
// screen before anything is sent (#508).
|
|
568
|
+
startWizard(verb);
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
571
|
+
if (verb !== "delete" || !onDeleteMessages || !focusedMessageId) {
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
629
574
|
requestDelete([focusedMessageId]);
|
|
630
575
|
return true;
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
576
|
+
},
|
|
577
|
+
[
|
|
578
|
+
pendingDelete,
|
|
579
|
+
onDeleteMessages,
|
|
580
|
+
hasSelection,
|
|
581
|
+
startWizard,
|
|
582
|
+
focusedMessageId,
|
|
583
|
+
requestDelete,
|
|
584
|
+
],
|
|
585
|
+
);
|
|
641
586
|
|
|
642
587
|
// Enter: open the focused row in the reading pane (sets selected/URL). This
|
|
643
588
|
// is the focus→open transition of the 2-state model.
|
|
@@ -678,63 +623,31 @@ export const MessageList = ({
|
|
|
678
623
|
open: followFocusOpen,
|
|
679
624
|
});
|
|
680
625
|
|
|
681
|
-
//
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
// Runs one bulk action and hands its outcome to the single choke point that
|
|
696
|
-
// decides what selection looks like afterwards. `ids` omitted means the
|
|
697
|
-
// escalated predicate (#114) — delete, move and mark-read all take this
|
|
698
|
-
// path, so an escalated selection is never delete-only.
|
|
699
|
-
const runEscalatedAction = useCallback(
|
|
700
|
-
async (action: EscalatedAction, ids?: string[]) => {
|
|
701
|
-
const outcome = await escalation.runAction(action, ids);
|
|
702
|
-
processRunOutcome(action, outcome);
|
|
703
|
-
},
|
|
704
|
-
[escalation, processRunOutcome],
|
|
705
|
-
);
|
|
706
|
-
|
|
707
|
-
const runChunkedConfirmDelete = useCallback(
|
|
708
|
-
async (ids: string[] | undefined) => {
|
|
709
|
-
setPendingDelete(null);
|
|
710
|
-
focusBeforeConfirmRef.current = null;
|
|
711
|
-
await runEscalatedAction(DELETE_ACTION, ids);
|
|
712
|
-
},
|
|
713
|
-
[runEscalatedAction],
|
|
714
|
-
);
|
|
626
|
+
// The escalated selection as the wizard walks it (#508): the words the bar
|
|
627
|
+
// already names the predicate with, the count it was escalated to, and the
|
|
628
|
+
// chunked runner that re-resolves it. The wizard's review screen stands in
|
|
629
|
+
// front of that runner and its run screen drives it, so nothing here reports
|
|
630
|
+
// an outcome of its own.
|
|
631
|
+
const escalatedSelection: EscalatedSelection | undefined =
|
|
632
|
+
escalation.phase.kind === "escalated"
|
|
633
|
+
? {
|
|
634
|
+
scope: describeSearchScope(searchPredicate ?? {}),
|
|
635
|
+
total: escalation.phase.total,
|
|
636
|
+
searchQuery: searchPredicate ?? {},
|
|
637
|
+
run: (action: EscalatedAction) => escalation.runAction(action),
|
|
638
|
+
}
|
|
639
|
+
: undefined;
|
|
715
640
|
|
|
716
|
-
// Confirm handler: run the actual
|
|
717
|
-
//
|
|
641
|
+
// Confirm handler: run the actual delete, then clear selection and move
|
|
642
|
+
// focus to a sensible neighbor (the row after the first deleted one).
|
|
718
643
|
const handleConfirmDelete = useCallback(() => {
|
|
719
644
|
if (!pendingDelete) return;
|
|
720
645
|
|
|
721
|
-
|
|
722
|
-
void runChunkedConfirmDelete(undefined);
|
|
723
|
-
return;
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
const { ids } = pendingDelete;
|
|
646
|
+
const ids = pendingDelete;
|
|
727
647
|
if (ids.length === 0) {
|
|
728
648
|
setPendingDelete(null);
|
|
729
649
|
return;
|
|
730
650
|
}
|
|
731
|
-
if (ids.length > BULK_ACTION_CHUNK_SIZE) {
|
|
732
|
-
// Selection stays put (still `ids`) for the duration of the run —
|
|
733
|
-
// `processRunOutcome` is the one place that clears it, on success,
|
|
734
|
-
// or replaces it with whatever's left to retry.
|
|
735
|
-
void runChunkedConfirmDelete(ids);
|
|
736
|
-
return;
|
|
737
|
-
}
|
|
738
651
|
|
|
739
652
|
const deletedSet = new Set(ids);
|
|
740
653
|
const firstDeletedIndex = threads.findIndex((t) =>
|
|
@@ -784,9 +697,9 @@ export const MessageList = ({
|
|
|
784
697
|
}
|
|
785
698
|
}
|
|
786
699
|
|
|
787
|
-
// Mobile keeps the list up, so it needs its own signal the delete landed
|
|
788
|
-
// the
|
|
789
|
-
//
|
|
700
|
+
// Mobile keeps the list up, so it needs its own signal the delete landed
|
|
701
|
+
// (#202). On desktop the rows leaving the list beside the reading pane is
|
|
702
|
+
// signal enough.
|
|
790
703
|
if (!isDesktop) {
|
|
791
704
|
setCompletionBanner(bulkActionCompletionText("delete", ids.length));
|
|
792
705
|
}
|
|
@@ -798,7 +711,6 @@ export const MessageList = ({
|
|
|
798
711
|
navigate,
|
|
799
712
|
mailboxId,
|
|
800
713
|
isDesktop,
|
|
801
|
-
runChunkedConfirmDelete,
|
|
802
714
|
setFocusedMessageId,
|
|
803
715
|
]);
|
|
804
716
|
|
|
@@ -816,48 +728,6 @@ export const MessageList = ({
|
|
|
816
728
|
setFocusedMessageId(restoreTo);
|
|
817
729
|
}, [setFocusedMessageId]);
|
|
818
730
|
|
|
819
|
-
// Mark read, bounded and escalated alike (#114). Both go through the same
|
|
820
|
-
// chunked run: the bulk flags call caps at 100 ids, so a selection past
|
|
821
|
-
// that would 400 as a single call, and an escalated selection has no id
|
|
822
|
-
// list at all — it pages its predicate instead.
|
|
823
|
-
const handleMarkAsRead = useCallback(() => {
|
|
824
|
-
if (escalation.phase.kind === "escalated") {
|
|
825
|
-
void runEscalatedAction(MARK_READ_ACTION);
|
|
826
|
-
return;
|
|
827
|
-
}
|
|
828
|
-
if (selectedCount === 0) return;
|
|
829
|
-
void runEscalatedAction(MARK_READ_ACTION, Array.from(selectedIds));
|
|
830
|
-
}, [escalation.phase, runEscalatedAction, selectedCount, selectedIds]);
|
|
831
|
-
|
|
832
|
-
// Move to a chosen folder, escalated selections included (#114).
|
|
833
|
-
const handleMoveSelected = useCallback(
|
|
834
|
-
(destinationMailboxId: string) => {
|
|
835
|
-
if (escalation.phase.kind === "escalated") {
|
|
836
|
-
void runEscalatedAction({ kind: "move", destinationMailboxId });
|
|
837
|
-
return;
|
|
838
|
-
}
|
|
839
|
-
if (!onMoveMessages || selectedCount === 0) return;
|
|
840
|
-
onMoveMessages(Array.from(selectedIds), destinationMailboxId);
|
|
841
|
-
exitSelection();
|
|
842
|
-
},
|
|
843
|
-
[
|
|
844
|
-
escalation.phase,
|
|
845
|
-
runEscalatedAction,
|
|
846
|
-
onMoveMessages,
|
|
847
|
-
selectedCount,
|
|
848
|
-
selectedIds,
|
|
849
|
-
exitSelection,
|
|
850
|
-
],
|
|
851
|
-
);
|
|
852
|
-
|
|
853
|
-
// Junk quick action (mobile sheet): move the selection to the appointed Junk
|
|
854
|
-
// mailbox. Reuses the same bounded/escalated move path as any other move, so
|
|
855
|
-
// the chunked run and the cross-account guard apply unchanged.
|
|
856
|
-
const handleJunk = useCallback(() => {
|
|
857
|
-
if (!junkMailboxId) return;
|
|
858
|
-
handleMoveSelected(junkMailboxId);
|
|
859
|
-
}, [junkMailboxId, handleMoveSelected]);
|
|
860
|
-
|
|
861
731
|
// Cross-account guard: every selected thread row must belong to the
|
|
862
732
|
// same account as the current mailbox. The list is already scoped to
|
|
863
733
|
// one mailbox so in practice this is always single-account, but we
|
|
@@ -881,22 +751,20 @@ export const MessageList = ({
|
|
|
881
751
|
return undefined;
|
|
882
752
|
}, [selectedCount, selectedIds, threads]);
|
|
883
753
|
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
//
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
[openWizard],
|
|
899
|
-
);
|
|
754
|
+
// Organize builds a rule out of clauses, and a search predicate is not a set
|
|
755
|
+
// of clauses — its facets have no `ClauseField`. Over an escalated selection
|
|
756
|
+
// the verb therefore opens the wizard through the search entry instead, on the
|
|
757
|
+
// property step with the query already converted (#477 1.8): the same door the
|
|
758
|
+
// make-filter affordance opens, which the bar hides while rows are ticked. The
|
|
759
|
+
// control stays pressable and lands somewhere that works (#477 1.7) rather
|
|
760
|
+
// than disappearing the moment a selection escalates.
|
|
761
|
+
const organizeSelection = useCallback(() => {
|
|
762
|
+
if (escalatedSelection) {
|
|
763
|
+
startFromSearch();
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
startWizard("organize");
|
|
767
|
+
}, [escalatedSelection, startFromSearch, startWizard]);
|
|
900
768
|
|
|
901
769
|
// Swipe-to-delete single message
|
|
902
770
|
const handleSwipeDelete = useCallback(
|
|
@@ -925,17 +793,6 @@ export const MessageList = ({
|
|
|
925
793
|
[isDesktop, select],
|
|
926
794
|
);
|
|
927
795
|
|
|
928
|
-
// The selection bar's Trash tap (both surfaces): an escalated selection has
|
|
929
|
-
// no materialized ids to hand `requestDelete`, so it opens the predicate
|
|
930
|
-
// confirmation instead.
|
|
931
|
-
const handleSelectionDelete = useCallback(() => {
|
|
932
|
-
if (escalation.phase.kind === "escalated") {
|
|
933
|
-
requestEscalatedDelete();
|
|
934
|
-
return;
|
|
935
|
-
}
|
|
936
|
-
handleDelete();
|
|
937
|
-
}, [escalation.phase, requestEscalatedDelete, handleDelete]);
|
|
938
|
-
|
|
939
796
|
// The selection bar's X (both surfaces): means "stop what's happening"
|
|
940
797
|
// throughout, not just "cancel selection" (issue #92 — the review flagged
|
|
941
798
|
// the X reading as ambiguous once a run is going). Counting and a chunked
|
|
@@ -955,21 +812,6 @@ export const MessageList = ({
|
|
|
955
812
|
escalation.clear();
|
|
956
813
|
}, [escalation]);
|
|
957
814
|
|
|
958
|
-
// Partial-failure notice's Retry: rerun the same action against exactly the
|
|
959
|
-
// ids it never reached last time (`selectedIds`, materialized there by
|
|
960
|
-
// `processRunOutcome`) — never the original selection. A retried delete
|
|
961
|
-
// keeps going through the confirmation path so the dialog's own state is
|
|
962
|
-
// cleared with it.
|
|
963
|
-
const handleRetryFailed = useCallback(() => {
|
|
964
|
-
if (!lastRun) return;
|
|
965
|
-
const ids = Array.from(selectedIds);
|
|
966
|
-
if (lastRun.action.kind === "delete") {
|
|
967
|
-
void runChunkedConfirmDelete(ids);
|
|
968
|
-
return;
|
|
969
|
-
}
|
|
970
|
-
void runEscalatedAction(lastRun.action, ids);
|
|
971
|
-
}, [lastRun, runChunkedConfirmDelete, runEscalatedAction, selectedIds]);
|
|
972
|
-
|
|
973
815
|
// Scroll the roving focus cursor into view as it moves (j/k). Falls back to
|
|
974
816
|
// the open thread when nothing is focused yet.
|
|
975
817
|
useEffect(() => {
|
|
@@ -1019,13 +861,14 @@ export const MessageList = ({
|
|
|
1019
861
|
focusedMessageId,
|
|
1020
862
|
selectedIds: Array.from(selectedIds),
|
|
1021
863
|
hasList: commandsAvailable,
|
|
1022
|
-
blocksKeyboard: confirmOpen,
|
|
864
|
+
blocksKeyboard: confirmOpen || wizard.isOpen,
|
|
1023
865
|
});
|
|
1024
866
|
}, [
|
|
1025
867
|
focusedMessageId,
|
|
1026
868
|
selectedIds,
|
|
1027
869
|
commandsAvailable,
|
|
1028
870
|
confirmOpen,
|
|
871
|
+
wizard.isOpen,
|
|
1029
872
|
onTriageContextChange,
|
|
1030
873
|
]);
|
|
1031
874
|
|
|
@@ -1050,18 +893,12 @@ export const MessageList = ({
|
|
|
1050
893
|
// only the ids that left and keeping every survivor — K-9's
|
|
1051
894
|
// `selected.intersect(uniqueIds)`, the reference behavior #92's D2 cites.
|
|
1052
895
|
// Wiping the whole selection because one id left (#111) cost the other 49
|
|
1053
|
-
// rows on an ordinary refresh
|
|
1054
|
-
// selection with it: `processRunOutcome` materializes the failed ids as
|
|
1055
|
-
// the new selection and resets this effect to live by returning escalation
|
|
1056
|
-
// to idle, so the cache invalidation's refetch ran this same effect against
|
|
1057
|
-
// the retry set — a clear here would have dropped the Retry notice
|
|
1058
|
-
// (gated on `selectedCount > 0`) along with it.
|
|
896
|
+
// rows on an ordinary refresh.
|
|
1059
897
|
// Skipped while an escalated run is active: `selectedIds` there is a stale
|
|
1060
898
|
// loaded-rows snapshot from the moment escalation started (the real
|
|
1061
899
|
// selection is the predicate, D2), not something a background refetch
|
|
1062
900
|
// reshuffling `threads` should be allowed to narrow out from under a count
|
|
1063
|
-
// or a
|
|
1064
|
-
// mid-run.
|
|
901
|
+
// or a run in progress — that would silently exit selection mode mid-run.
|
|
1065
902
|
useEffect(() => {
|
|
1066
903
|
if (escalation.phase.kind !== "idle" || escalation.isRunning) return;
|
|
1067
904
|
intersectWith(threads.map((t) => t.messageId));
|
|
@@ -1153,7 +990,7 @@ export const MessageList = ({
|
|
|
1153
990
|
exitSelection();
|
|
1154
991
|
return true;
|
|
1155
992
|
},
|
|
1156
|
-
|
|
993
|
+
requestVerb,
|
|
1157
994
|
toggleDensity,
|
|
1158
995
|
};
|
|
1159
996
|
return () => {
|
|
@@ -1173,7 +1010,7 @@ export const MessageList = ({
|
|
|
1173
1010
|
handleSelectAll,
|
|
1174
1011
|
hasSelection,
|
|
1175
1012
|
exitSelection,
|
|
1176
|
-
|
|
1013
|
+
requestVerb,
|
|
1177
1014
|
toggleDensity,
|
|
1178
1015
|
]);
|
|
1179
1016
|
|
|
@@ -1262,10 +1099,8 @@ export const MessageList = ({
|
|
|
1262
1099
|
|
|
1263
1100
|
// At most one escalation notice at a time, ranked by how actionable it is:
|
|
1264
1101
|
// an in-progress counting/escalated state and its own action always wins;
|
|
1265
|
-
// otherwise a fresh escalation offer
|
|
1266
|
-
//
|
|
1267
|
-
// surface below — the desktop toolbar shows it inline, the mobile sheet
|
|
1268
|
-
// folds it in as the lowest-priority notice.
|
|
1102
|
+
// otherwise a fresh escalation offer. The (rare) cross-account move hint is
|
|
1103
|
+
// layered on behind them below.
|
|
1269
1104
|
const escalationNotice =
|
|
1270
1105
|
escalation.phase.kind === "counting"
|
|
1271
1106
|
? {
|
|
@@ -1291,36 +1126,7 @@ export const MessageList = ({
|
|
|
1291
1126
|
onClick: escalation.escalate,
|
|
1292
1127
|
},
|
|
1293
1128
|
}
|
|
1294
|
-
:
|
|
1295
|
-
? {
|
|
1296
|
-
tone: "danger" as const,
|
|
1297
|
-
text: bulkActionPartialText(
|
|
1298
|
-
lastRun.action.kind,
|
|
1299
|
-
lastRun.succeeded,
|
|
1300
|
-
selectedCount,
|
|
1301
|
-
),
|
|
1302
|
-
action: {
|
|
1303
|
-
label: `Retry ${formatNumber(selectedCount)}`,
|
|
1304
|
-
onClick: handleRetryFailed,
|
|
1305
|
-
},
|
|
1306
|
-
}
|
|
1307
|
-
: undefined;
|
|
1308
|
-
|
|
1309
|
-
// An escalated selection is a predicate the wizard cannot take, so Move there
|
|
1310
|
-
// keeps the caller's own folder picker and the chunked predicate run (#114).
|
|
1311
|
-
const selectionMoveSlot =
|
|
1312
|
-
!canOpenWizard &&
|
|
1313
|
-
(onMoveMessages || escalation.phase.kind === "escalated") &&
|
|
1314
|
-
accountId &&
|
|
1315
|
-
mailboxId ? (
|
|
1316
|
-
<MoveToTrigger
|
|
1317
|
-
accountId={accountId}
|
|
1318
|
-
currentMailboxId={mailboxId}
|
|
1319
|
-
onMove={isDeleting || isMoving ? () => {} : handleMoveSelected}
|
|
1320
|
-
disabledHint={moveDisabledHint}
|
|
1321
|
-
label="Move selected messages"
|
|
1322
|
-
/>
|
|
1323
|
-
) : undefined;
|
|
1129
|
+
: undefined;
|
|
1324
1130
|
|
|
1325
1131
|
// The bar shows one notice at a time, so the cross-account move restriction
|
|
1326
1132
|
// rides in behind the escalation states.
|
|
@@ -1346,24 +1152,15 @@ export const MessageList = ({
|
|
|
1346
1152
|
idleSlot={listHeaderChrome.makeFilterSlot}
|
|
1347
1153
|
count={selectionCount}
|
|
1348
1154
|
onCancel={handleSelectionCancel}
|
|
1349
|
-
onDelete={
|
|
1350
|
-
|
|
1351
|
-
}
|
|
1352
|
-
onMove={canOpenWizard ? () => startWizard("move") : undefined}
|
|
1353
|
-
moveSlot={selectionMoveSlot}
|
|
1354
|
-
onOrganize={canOpenWizard ? () => startWizard("organize") : undefined}
|
|
1155
|
+
onDelete={() => startWizard("delete")}
|
|
1156
|
+
onMove={() => startWizard("move")}
|
|
1157
|
+
onOrganize={organizeSelection}
|
|
1355
1158
|
onJunk={
|
|
1356
1159
|
junkMailboxId && junkMailboxId !== mailboxId
|
|
1357
|
-
?
|
|
1358
|
-
? () => startWizard("junk")
|
|
1359
|
-
: !moveDisabledHint
|
|
1360
|
-
? handleJunk
|
|
1361
|
-
: undefined
|
|
1160
|
+
? () => startWizard("junk")
|
|
1362
1161
|
: undefined
|
|
1363
1162
|
}
|
|
1364
|
-
onMarkRead={
|
|
1365
|
-
canOpenWizard ? () => startWizard("markRead") : handleMarkAsRead
|
|
1366
|
-
}
|
|
1163
|
+
onMarkRead={() => startWizard("markRead")}
|
|
1367
1164
|
overflowSlot={
|
|
1368
1165
|
accountId && mailboxId && selectedCount > 0 && labels.length > 0 ? (
|
|
1369
1166
|
<LabelApplyTrigger
|
|
@@ -1465,20 +1262,6 @@ export const MessageList = ({
|
|
|
1465
1262
|
</>
|
|
1466
1263
|
);
|
|
1467
1264
|
|
|
1468
|
-
const pendingDeleteCount = pendingDelete
|
|
1469
|
-
? pendingDelete.source === "ids"
|
|
1470
|
-
? pendingDelete.ids.length
|
|
1471
|
-
: pendingDelete.total
|
|
1472
|
-
: 0;
|
|
1473
|
-
|
|
1474
|
-
// The predicate case (#109): `pendingDelete.total` is `countMatches`'s
|
|
1475
|
-
// frozen page-through, and the delete itself re-pages the same predicate a
|
|
1476
|
-
// second, independent time. Mail arriving or leaving between the two can
|
|
1477
|
-
// make them differ, so the dialog says "about" instead of promising an
|
|
1478
|
-
// exact number it may not honour. A materialized (bounded) selection's
|
|
1479
|
-
// count is exact — it's the delete's own input, not an estimate of it.
|
|
1480
|
-
const pendingDeleteIsEstimate = pendingDelete?.source === "predicate";
|
|
1481
|
-
|
|
1482
1265
|
return (
|
|
1483
1266
|
<>
|
|
1484
1267
|
{completionBanner && !isDesktop && (
|
|
@@ -1525,15 +1308,8 @@ export const MessageList = ({
|
|
|
1525
1308
|
/>
|
|
1526
1309
|
<ConfirmDialog
|
|
1527
1310
|
isOpen={pendingDelete !== null}
|
|
1528
|
-
title={formatDeleteToTrashTitle(
|
|
1529
|
-
|
|
1530
|
-
pendingDeleteIsEstimate,
|
|
1531
|
-
)}
|
|
1532
|
-
description={
|
|
1533
|
-
pendingDeleteIsEstimate
|
|
1534
|
-
? "This count is a snapshot — new mail arriving during the delete won't be included. You can restore what's deleted from Trash later."
|
|
1535
|
-
: "You can restore them from Trash later."
|
|
1536
|
-
}
|
|
1311
|
+
title={formatDeleteToTrashTitle(pendingDelete?.length ?? 0)}
|
|
1312
|
+
description="You can restore them from Trash later."
|
|
1537
1313
|
confirmLabel="Move to Trash"
|
|
1538
1314
|
destructive
|
|
1539
1315
|
isBusy={isDeleting}
|
|
@@ -1546,6 +1322,8 @@ export const MessageList = ({
|
|
|
1546
1322
|
mailboxId={mailboxId}
|
|
1547
1323
|
selection={wizardSelection}
|
|
1548
1324
|
crossAccount={moveDisabledHint !== undefined}
|
|
1325
|
+
escalated={escalatedSelection}
|
|
1326
|
+
escalatedProgress={escalation.progress}
|
|
1549
1327
|
onFinished={exitSelection}
|
|
1550
1328
|
/>
|
|
1551
1329
|
</>
|