@remit/web-client 0.0.91 → 0.0.93
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/DailyBrief.selection.test.ts +6 -2
- package/src/components/mail/DailyBrief.tsx +47 -110
- package/src/components/mail/MessageList.selection.test.ts +18 -6
- package/src/components/mail/MessageList.tsx +83 -83
- package/src/components/mail/SelectionWizardHost.tsx +779 -0
- package/src/components/mail/organize/SearchFilterEditor.tsx +5 -1
- package/src/components/settings/FilterEditor.tsx +1 -1
- package/src/hooks/useCreateMailbox.ts +16 -4
- package/src/hooks/useFilters.ts +30 -1
- package/src/hooks/useMatchSample.ts +63 -0
- package/src/hooks/useRulePreview.ts +23 -3
- package/src/lib/organize/organize-model.test.ts +110 -0
- package/src/lib/organize/organize-model.ts +69 -0
- package/src/lib/organize/rule-model.ts +2 -0
- package/src/lib/selection-mode.test.ts +20 -6
- package/src/lib/selection-mode.ts +8 -1
- package/src/lib/wizard-history.test.ts +176 -0
- package/src/lib/wizard-history.ts +131 -0
- package/src/routes/mail.tsx +4 -0
- package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +0 -45
- package/src/components/mail/organize/MobileOrganizeFlow.tsx +0 -157
- package/src/components/mail/organize/OrganizeDialog.render.test.ts +0 -37
- package/src/components/mail/organize/OrganizeDialog.tsx +0 -91
- package/src/components/mail/organize/OrganizeRuleEditor.render.test.ts +0 -498
- package/src/components/mail/organize/OrganizeRuleEditor.tsx +0 -285
- package/src/components/mail/organize/SomethingElsePanel.render.test.ts +0 -54
- package/src/components/mail/organize/SomethingElsePanel.tsx +0 -159
- package/src/components/mail/organize/smart-organize.stories.tsx +0 -342
- package/src/lib/organize/mobile-organize-flow.test.ts +0 -96
- package/src/lib/organize/mobile-organize-flow.ts +0 -73
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.93",
|
|
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": {
|
|
@@ -47,7 +47,11 @@ describe("the brief's select-all", () => {
|
|
|
47
47
|
);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
-
it("
|
|
51
|
-
|
|
50
|
+
it("sends every verb on the bar into the wizard (#477 1.4)", () => {
|
|
51
|
+
const body = chromeBody();
|
|
52
|
+
for (const verb of ["delete", "move", "junk", "markRead", "organize"]) {
|
|
53
|
+
assert.match(body, new RegExp(`startWizard\\("${verb}"\\)`));
|
|
54
|
+
}
|
|
55
|
+
assert.doesNotMatch(body, /onSomethingElse/);
|
|
52
56
|
});
|
|
53
57
|
});
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
SelectionTopBar,
|
|
47
47
|
type ThreadRowData,
|
|
48
48
|
type ThreadSection,
|
|
49
|
+
type Verb,
|
|
49
50
|
} from "@remit/ui";
|
|
50
51
|
import { useQueries, useQuery } from "@tanstack/react-query";
|
|
51
52
|
import { useNavigate } from "@tanstack/react-router";
|
|
@@ -54,7 +55,6 @@ import {
|
|
|
54
55
|
type ReactNode,
|
|
55
56
|
type RefObject,
|
|
56
57
|
useCallback,
|
|
57
|
-
useEffect,
|
|
58
58
|
useMemo,
|
|
59
59
|
useState,
|
|
60
60
|
} from "react";
|
|
@@ -65,7 +65,6 @@ import {
|
|
|
65
65
|
} from "@/hooks/useInitialSyncProgress";
|
|
66
66
|
import { useLabelList } from "@/hooks/useLabels";
|
|
67
67
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
68
|
-
import { useMoveMessages } from "@/hooks/useMoveMessages";
|
|
69
68
|
import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
|
|
70
69
|
import { useSemanticSearch } from "@/hooks/useSemanticSearch";
|
|
71
70
|
import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
|
|
@@ -83,13 +82,15 @@ import type { ListHeaderChrome } from "@/lib/list-header-chrome";
|
|
|
83
82
|
import { useMailContext } from "@/lib/mail-context";
|
|
84
83
|
import { relatedSearchResults, rowToSearchResult } from "@/lib/search-result";
|
|
85
84
|
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
85
|
+
import { useOpenWizard } from "@/lib/wizard-history";
|
|
86
86
|
import { LabelApplyTrigger } from "./LabelApplyTrigger";
|
|
87
87
|
import { MailListHeader, type MailListHeaderProps } from "./MailListHeader";
|
|
88
88
|
import type { MessageListCommands } from "./MessageList";
|
|
89
89
|
import { MessageRow } from "./MessageRow";
|
|
90
|
-
import {
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
import {
|
|
91
|
+
SelectionWizardHost,
|
|
92
|
+
type WizardSelectionMessage,
|
|
93
|
+
} from "./SelectionWizardHost";
|
|
93
94
|
import {
|
|
94
95
|
type OpenMessageOptions,
|
|
95
96
|
ThreadListInteraction,
|
|
@@ -293,7 +294,6 @@ interface BriefSelectionChromeProps {
|
|
|
293
294
|
>;
|
|
294
295
|
/** The rows the list is showing, for resolving the selection's scope. */
|
|
295
296
|
rows: readonly ThreadRowData[];
|
|
296
|
-
isDesktop: boolean;
|
|
297
297
|
onMarkMessagesRead?: (messageIds: string[]) => void;
|
|
298
298
|
children: ReactNode;
|
|
299
299
|
}
|
|
@@ -307,7 +307,6 @@ interface BriefSelectionChromeProps {
|
|
|
307
307
|
function BriefSelectionChrome({
|
|
308
308
|
header,
|
|
309
309
|
rows,
|
|
310
|
-
isDesktop,
|
|
311
310
|
onMarkMessagesRead,
|
|
312
311
|
children,
|
|
313
312
|
}: BriefSelectionChromeProps) {
|
|
@@ -315,7 +314,6 @@ function BriefSelectionChrome({
|
|
|
315
314
|
selectedIds,
|
|
316
315
|
selectedCount,
|
|
317
316
|
exitSelection,
|
|
318
|
-
requestDeleteSelection,
|
|
319
317
|
orderedIds,
|
|
320
318
|
allSelected,
|
|
321
319
|
toggleAllLoaded,
|
|
@@ -327,57 +325,40 @@ function BriefSelectionChrome({
|
|
|
327
325
|
);
|
|
328
326
|
const { junkMailboxId } = useJunkMailbox(scope.accountId);
|
|
329
327
|
const { labels } = useLabelList(scope.accountId);
|
|
330
|
-
const { moveMessages, isPending: isMoving } = useMoveMessages({
|
|
331
|
-
mailboxId: scope.mailboxId ?? "",
|
|
332
|
-
accountId: scope.accountId,
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
const [organizeOpen, setOrganizeOpen] = useState(false);
|
|
336
|
-
const [mobileOrganizeEntry, setMobileOrganizeEntry] = useState<
|
|
337
|
-
"select-similar" | "something-else" | null
|
|
338
|
-
>(null);
|
|
339
328
|
|
|
340
|
-
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
329
|
+
const openWizard = useOpenWizard();
|
|
330
|
+
// The verb the bar was pressed for. The wizard is open for as long as the URL
|
|
331
|
+
// holds a step, so a back that pops the last one closes it.
|
|
332
|
+
const [wizardVerb, setWizardVerb] = useState<Verb>("organize");
|
|
333
|
+
const startWizard = useCallback(
|
|
334
|
+
(verb: Verb) => {
|
|
335
|
+
setWizardVerb(verb);
|
|
336
|
+
openWizard("match");
|
|
337
|
+
},
|
|
338
|
+
[openWizard],
|
|
339
|
+
);
|
|
347
340
|
|
|
348
341
|
const selectedMessageIds = useMemo(
|
|
349
342
|
() => Array.from(selectedIds),
|
|
350
343
|
[selectedIds],
|
|
351
344
|
);
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
(destinationMailboxId: string) => {
|
|
367
|
-
if (selectedMessageIds.length === 0) return;
|
|
368
|
-
moveMessages(selectedMessageIds, destinationMailboxId);
|
|
369
|
-
exitSelection();
|
|
370
|
-
},
|
|
371
|
-
[moveMessages, selectedMessageIds, exitSelection],
|
|
345
|
+
// The ticked rows as the wizard reads them — the sample under every screen
|
|
346
|
+
// that names a match, and the senders its widen falls back to.
|
|
347
|
+
const wizardSelection = useMemo<WizardSelectionMessage[]>(
|
|
348
|
+
() =>
|
|
349
|
+
rows
|
|
350
|
+
.filter((row) => selectedIds.has(row.id))
|
|
351
|
+
.map((row) => ({
|
|
352
|
+
id: row.id,
|
|
353
|
+
sender: row.fromName,
|
|
354
|
+
email: row.fromEmail,
|
|
355
|
+
subject: row.subject,
|
|
356
|
+
date: row.timeLabel,
|
|
357
|
+
})),
|
|
358
|
+
[rows, selectedIds],
|
|
372
359
|
);
|
|
373
360
|
|
|
374
|
-
const
|
|
375
|
-
if (junkMailboxId) handleMove(junkMailboxId);
|
|
376
|
-
}, [junkMailboxId, handleMove]);
|
|
377
|
-
|
|
378
|
-
const scoped = !!scope.accountId && !!scope.mailboxId;
|
|
379
|
-
const canJunk =
|
|
380
|
-
scoped && !!junkMailboxId && junkMailboxId !== scope.mailboxId;
|
|
361
|
+
const canJunk = !!junkMailboxId && junkMailboxId !== scope.mailboxId;
|
|
381
362
|
|
|
382
363
|
// One select-all for both surfaces: the desktop toolbar and the touch sheet
|
|
383
364
|
// offer the same control over the same rendered rows, so the verb a phone
|
|
@@ -410,31 +391,12 @@ function BriefSelectionChrome({
|
|
|
410
391
|
idleSlot={chrome.makeFilterSlot}
|
|
411
392
|
count={selectedCount}
|
|
412
393
|
onCancel={exitSelection}
|
|
413
|
-
onDelete={
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
: setMobileOrganizeEntry("select-similar")
|
|
420
|
-
: undefined
|
|
421
|
-
}
|
|
422
|
-
onJunk={canJunk ? handleJunk : undefined}
|
|
423
|
-
onMarkRead={onMarkMessagesRead ? handleMarkAsRead : undefined}
|
|
424
|
-
onSomethingElse={
|
|
425
|
-
scoped && !isDesktop
|
|
426
|
-
? () => setMobileOrganizeEntry("something-else")
|
|
427
|
-
: undefined
|
|
428
|
-
}
|
|
429
|
-
moveSlot={
|
|
430
|
-
scoped && scope.accountId && scope.mailboxId ? (
|
|
431
|
-
<MoveToTrigger
|
|
432
|
-
accountId={scope.accountId}
|
|
433
|
-
currentMailboxId={scope.mailboxId}
|
|
434
|
-
onMove={isMoving ? () => {} : handleMove}
|
|
435
|
-
label="Move selected messages"
|
|
436
|
-
/>
|
|
437
|
-
) : undefined
|
|
394
|
+
onDelete={() => startWizard("delete")}
|
|
395
|
+
onMove={() => startWizard("move")}
|
|
396
|
+
onOrganize={() => startWizard("organize")}
|
|
397
|
+
onJunk={canJunk ? () => startWizard("junk") : undefined}
|
|
398
|
+
onMarkRead={
|
|
399
|
+
onMarkMessagesRead ? () => startWizard("markRead") : undefined
|
|
438
400
|
}
|
|
439
401
|
overflowSlot={
|
|
440
402
|
scope.accountId &&
|
|
@@ -448,48 +410,24 @@ function BriefSelectionChrome({
|
|
|
448
410
|
/>
|
|
449
411
|
) : undefined
|
|
450
412
|
}
|
|
451
|
-
isBusy={isMoving}
|
|
452
413
|
selectAll={selectAll}
|
|
453
414
|
notice={notice}
|
|
454
415
|
/>
|
|
455
416
|
);
|
|
456
417
|
|
|
457
|
-
const organizeFlow =
|
|
458
|
-
mobileOrganizeEntry &&
|
|
459
|
-
scope.accountId &&
|
|
460
|
-
!isDesktop &&
|
|
461
|
-
selectedCount > 0 ? (
|
|
462
|
-
<MobileOrganizeFlow
|
|
463
|
-
entry={mobileOrganizeEntry}
|
|
464
|
-
accountId={scope.accountId}
|
|
465
|
-
selectedMessageIds={selectedMessageIds}
|
|
466
|
-
selectedSenders={selectedSenders}
|
|
467
|
-
junkMailboxId={junkMailboxId}
|
|
468
|
-
onClose={() => {
|
|
469
|
-
setMobileOrganizeEntry(null);
|
|
470
|
-
exitSelection();
|
|
471
|
-
}}
|
|
472
|
-
/>
|
|
473
|
-
) : undefined;
|
|
474
|
-
|
|
475
418
|
return (
|
|
476
419
|
<>
|
|
477
|
-
<MailListHeader
|
|
478
|
-
{...header}
|
|
479
|
-
selectionBar={selectionBar}
|
|
480
|
-
paneOverlay={organizeFlow}
|
|
481
|
-
>
|
|
420
|
+
<MailListHeader {...header} selectionBar={selectionBar}>
|
|
482
421
|
{children}
|
|
483
422
|
</MailListHeader>
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
)}
|
|
423
|
+
<SelectionWizardHost
|
|
424
|
+
verb={wizardVerb}
|
|
425
|
+
accountId={scope.accountId}
|
|
426
|
+
mailboxId={scope.mailboxId}
|
|
427
|
+
selection={wizardSelection}
|
|
428
|
+
crossAccount={scope.moveDisabledHint !== undefined}
|
|
429
|
+
onFinished={exitSelection}
|
|
430
|
+
/>
|
|
493
431
|
</>
|
|
494
432
|
);
|
|
495
433
|
}
|
|
@@ -850,7 +788,6 @@ export function DailyBrief({
|
|
|
850
788
|
onSelectSearchResult,
|
|
851
789
|
}}
|
|
852
790
|
rows={filteredRows}
|
|
853
|
-
isDesktop={isDesktop}
|
|
854
791
|
onMarkMessagesRead={onMarkMessagesRead}
|
|
855
792
|
>
|
|
856
793
|
<div className="flex h-full flex-col">
|
|
@@ -45,10 +45,17 @@ describe("MessageList selection mode", () => {
|
|
|
45
45
|
it("turns the back gesture into an exit, and only while selecting", () => {
|
|
46
46
|
assert.match(
|
|
47
47
|
source,
|
|
48
|
-
/shouldExitSelectionOnNavigate\(action, hasSelection\)/,
|
|
48
|
+
/shouldExitSelectionOnNavigate\(action, hasSelection, wizardStep\)/,
|
|
49
49
|
);
|
|
50
50
|
assert.match(source, /disabled: !hasSelection/);
|
|
51
51
|
});
|
|
52
|
+
|
|
53
|
+
it("leaves the back gesture to the wizard while the wizard is open", () => {
|
|
54
|
+
assert.match(
|
|
55
|
+
source,
|
|
56
|
+
/disabled: !hasSelection \|\| wizardStep !== undefined/,
|
|
57
|
+
);
|
|
58
|
+
});
|
|
52
59
|
});
|
|
53
60
|
|
|
54
61
|
/**
|
|
@@ -71,8 +78,9 @@ describe("MessageList escalated actions", () => {
|
|
|
71
78
|
|
|
72
79
|
it("keeps mark-read and the move slot on an escalated selection", () => {
|
|
73
80
|
// The bar always carries the mark-read verb (it drops it itself while
|
|
74
|
-
// counting or busy); an escalated selection must never lose it
|
|
75
|
-
|
|
81
|
+
// counting or busy); an escalated selection must never lose it — it falls
|
|
82
|
+
// back to the predicate run, which the wizard cannot take.
|
|
83
|
+
assert.match(source, /: handleMarkAsRead\s*\}/);
|
|
76
84
|
// The move slot is offered for a bounded selection or an escalated one —
|
|
77
85
|
// #114's rule that an escalated selection is never delete-only.
|
|
78
86
|
assert.match(
|
|
@@ -135,9 +143,13 @@ describe("MessageList escalation reaches desktop (#212)", () => {
|
|
|
135
143
|
assert.match(source, /navSlot=\{listHeaderChrome\.navSlot\}/);
|
|
136
144
|
});
|
|
137
145
|
|
|
138
|
-
it("
|
|
139
|
-
|
|
140
|
-
|
|
146
|
+
it("sends every verb on the bar into the wizard (#477 1.4)", () => {
|
|
147
|
+
const bar = source.match(/<SelectionTopBar[\s\S]*?\n\t\t\/>/)?.[0] ?? "";
|
|
148
|
+
for (const verb of ["delete", "move", "junk", "markRead", "organize"]) {
|
|
149
|
+
assert.match(bar, new RegExp(`startWizard\\("${verb}"\\)`));
|
|
150
|
+
}
|
|
151
|
+
// The one selection surface has no second organize entry beside them.
|
|
152
|
+
assert.doesNotMatch(source, /onSomethingElse/);
|
|
141
153
|
});
|
|
142
154
|
|
|
143
155
|
it("offers the label picker only when the account has labels", () => {
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
MessageListLoadingMore,
|
|
7
7
|
MessageListPane,
|
|
8
8
|
SelectionTopBar,
|
|
9
|
+
type Verb,
|
|
9
10
|
} from "@remit/ui";
|
|
10
11
|
import { useBlocker, useNavigate } from "@tanstack/react-router";
|
|
11
12
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
@@ -44,7 +45,11 @@ import {
|
|
|
44
45
|
escalatedStatusLabel,
|
|
45
46
|
escalationActionLabel,
|
|
46
47
|
} from "@/lib/escalation-label";
|
|
47
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
formatDeleteToTrashTitle,
|
|
50
|
+
formatEmailDate,
|
|
51
|
+
formatNumber,
|
|
52
|
+
} from "@/lib/format";
|
|
48
53
|
import { tabStopId } from "@/lib/list-focus";
|
|
49
54
|
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
50
55
|
import {
|
|
@@ -52,10 +57,13 @@ import {
|
|
|
52
57
|
shouldExitSelectionOnNavigate,
|
|
53
58
|
} from "@/lib/selection-mode";
|
|
54
59
|
import { cn } from "@/lib/utils";
|
|
60
|
+
import { useOpenWizard, useWizardStepValue } from "@/lib/wizard-history";
|
|
55
61
|
import { LabelApplyTrigger } from "./LabelApplyTrigger";
|
|
56
62
|
import { MoveToTrigger } from "./MoveToTrigger";
|
|
57
|
-
import {
|
|
58
|
-
|
|
63
|
+
import {
|
|
64
|
+
SelectionWizardHost,
|
|
65
|
+
type WizardSelectionMessage,
|
|
66
|
+
} from "./SelectionWizardHost";
|
|
59
67
|
import { SwipeableMessageRow } from "./SwipeableMessageRow";
|
|
60
68
|
|
|
61
69
|
/**
|
|
@@ -232,13 +240,12 @@ export const MessageList = ({
|
|
|
232
240
|
const parentRef = useRef<HTMLDivElement>(null);
|
|
233
241
|
const navigate = useNavigate();
|
|
234
242
|
const isDesktop = useIsDesktop();
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
>(null);
|
|
243
|
+
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");
|
|
242
249
|
const isSearching = !!searchQuery?.trim();
|
|
243
250
|
const listHeaderChrome = useListHeaderChrome();
|
|
244
251
|
const { labels } = useLabelList(accountId);
|
|
@@ -315,13 +322,6 @@ export const MessageList = ({
|
|
|
315
322
|
// it back to the count, and across that render the two disagree.
|
|
316
323
|
const isMultiSelectMode = deriveIsMultiSelectMode(selectedCount, isDesktop);
|
|
317
324
|
|
|
318
|
-
// When the selection empties — cancel, a completed action, a mailbox switch —
|
|
319
|
-
// the guided organize entry must reset too, so a later re-selection doesn't
|
|
320
|
-
// reopen the flow on a stale entry (issue #211).
|
|
321
|
-
useEffect(() => {
|
|
322
|
-
if (selectedCount === 0) setMobileOrganizeEntry(null);
|
|
323
|
-
}, [selectedCount]);
|
|
324
|
-
|
|
325
325
|
// Pending delete, awaiting confirmation. `null` means the dialog is closed.
|
|
326
326
|
// `source: "ids"` snapshots the concrete ids at request time so a selection
|
|
327
327
|
// change behind the dialog can't retarget the delete — every keyboard/desktop
|
|
@@ -509,17 +509,23 @@ export const MessageList = ({
|
|
|
509
509
|
// false for a plain click (caller lets the Link navigate).
|
|
510
510
|
const orderedIds = useMemo(() => threads.map((t) => t.messageId), [threads]);
|
|
511
511
|
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
// no extra round-trip
|
|
516
|
-
const
|
|
517
|
-
const
|
|
512
|
+
// The checked rows as the wizard reads them: the sample it shows under every
|
|
513
|
+
// screen that names a match, and the senders its widen falls back to on a
|
|
514
|
+
// deployment with no vector pipeline. Read off the already-loaded thread rows,
|
|
515
|
+
// so no extra round-trip.
|
|
516
|
+
const wizardSelection = useMemo<WizardSelectionMessage[]>(() => {
|
|
517
|
+
const rows: WizardSelectionMessage[] = [];
|
|
518
518
|
for (const thread of threads) {
|
|
519
519
|
if (!selectedIds.has(thread.messageId)) continue;
|
|
520
|
-
|
|
520
|
+
rows.push({
|
|
521
|
+
id: thread.messageId,
|
|
522
|
+
sender: thread.fromName ?? thread.fromEmail ?? "Unknown",
|
|
523
|
+
email: thread.fromEmail ?? "",
|
|
524
|
+
subject: thread.subject ?? "(No subject)",
|
|
525
|
+
date: formatEmailDate(thread.sentDate),
|
|
526
|
+
});
|
|
521
527
|
}
|
|
522
|
-
return
|
|
528
|
+
return rows;
|
|
523
529
|
}, [threads, selectedIds]);
|
|
524
530
|
const handleRowSelect = useCallback(
|
|
525
531
|
(messageId: string, modifiers: SelectionModifiers): boolean => {
|
|
@@ -875,6 +881,23 @@ export const MessageList = ({
|
|
|
875
881
|
return undefined;
|
|
876
882
|
}, [selectedCount, selectedIds, threads]);
|
|
877
883
|
|
|
884
|
+
// Every verb on the bar opens the wizard on the ticked rows (#477 1.4),
|
|
885
|
+
// including a selection spanning accounts — the wizard is where that
|
|
886
|
+
// restriction is stated, on the step that needs one account (#477 5.5). It is
|
|
887
|
+
// withdrawn only once the selection escalates to a predicate or a run takes
|
|
888
|
+
// over: an escalated selection is a predicate, which no bounded list of ids
|
|
889
|
+
// stands in for (#477 3.1), and keeps the bar's own chunked runner.
|
|
890
|
+
const canOpenWizard =
|
|
891
|
+
escalation.phase.kind === "idle" && !escalation.isRunning;
|
|
892
|
+
|
|
893
|
+
const startWizard = useCallback(
|
|
894
|
+
(verb: Verb) => {
|
|
895
|
+
setWizardVerb(verb);
|
|
896
|
+
openWizard("match");
|
|
897
|
+
},
|
|
898
|
+
[openWizard],
|
|
899
|
+
);
|
|
900
|
+
|
|
878
901
|
// Swipe-to-delete single message
|
|
879
902
|
const handleSwipeDelete = useCallback(
|
|
880
903
|
(messageId: string) => {
|
|
@@ -1060,12 +1083,14 @@ export const MessageList = ({
|
|
|
1060
1083
|
// — is never blocked, and the blocker is off entirely with nothing selected.
|
|
1061
1084
|
useBlocker({
|
|
1062
1085
|
shouldBlockFn: ({ action }) => {
|
|
1063
|
-
if (!shouldExitSelectionOnNavigate(action, hasSelection))
|
|
1086
|
+
if (!shouldExitSelectionOnNavigate(action, hasSelection, wizardStep)) {
|
|
1087
|
+
return false;
|
|
1088
|
+
}
|
|
1064
1089
|
exitSelection();
|
|
1065
1090
|
return true;
|
|
1066
1091
|
},
|
|
1067
1092
|
enableBeforeUnload: false,
|
|
1068
|
-
disabled: !hasSelection,
|
|
1093
|
+
disabled: !hasSelection || wizardStep !== undefined,
|
|
1069
1094
|
});
|
|
1070
1095
|
|
|
1071
1096
|
// Load more when scrolling near the bottom
|
|
@@ -1281,15 +1306,10 @@ export const MessageList = ({
|
|
|
1281
1306
|
}
|
|
1282
1307
|
: undefined;
|
|
1283
1308
|
|
|
1284
|
-
//
|
|
1285
|
-
//
|
|
1286
|
-
const selectionNotice =
|
|
1287
|
-
escalationNotice ??
|
|
1288
|
-
(moveDisabledHint
|
|
1289
|
-
? { tone: "warning" as const, text: moveDisabledHint }
|
|
1290
|
-
: undefined);
|
|
1291
|
-
|
|
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).
|
|
1292
1311
|
const selectionMoveSlot =
|
|
1312
|
+
!canOpenWizard &&
|
|
1293
1313
|
(onMoveMessages || escalation.phase.kind === "escalated") &&
|
|
1294
1314
|
accountId &&
|
|
1295
1315
|
mailboxId ? (
|
|
@@ -1302,28 +1322,13 @@ export const MessageList = ({
|
|
|
1302
1322
|
/>
|
|
1303
1323
|
) : undefined;
|
|
1304
1324
|
|
|
1305
|
-
// The
|
|
1306
|
-
//
|
|
1307
|
-
const
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
selectedMessageIds={Array.from(selectedIds)}
|
|
1313
|
-
selectedSenders={selectedSenders}
|
|
1314
|
-
junkMailboxId={junkMailboxId}
|
|
1315
|
-
onClose={() => {
|
|
1316
|
-
setMobileOrganizeEntry(null);
|
|
1317
|
-
exitSelection();
|
|
1318
|
-
}}
|
|
1319
|
-
/>
|
|
1320
|
-
) : undefined;
|
|
1321
|
-
|
|
1322
|
-
// Organize acts on the materialized selection within one account, so it is
|
|
1323
|
-
// withdrawn the moment the selection escalates to a predicate or a run
|
|
1324
|
-
// takes over — both of which name themselves through `statusLabel`.
|
|
1325
|
-
const canOrganize =
|
|
1326
|
-
!!accountId && !!mailboxId && !moveDisabledHint && !selectionStatusLabel;
|
|
1325
|
+
// The bar shows one notice at a time, so the cross-account move restriction
|
|
1326
|
+
// rides in behind the escalation states.
|
|
1327
|
+
const selectionNotice =
|
|
1328
|
+
escalationNotice ??
|
|
1329
|
+
(moveDisabledHint
|
|
1330
|
+
? { tone: "warning" as const, text: moveDisabledHint }
|
|
1331
|
+
: undefined);
|
|
1327
1332
|
|
|
1328
1333
|
// One selection surface at every width, always mounted: it is the list
|
|
1329
1334
|
// header. With nothing ticked it names the mailbox and carries the header's
|
|
@@ -1341,27 +1346,24 @@ export const MessageList = ({
|
|
|
1341
1346
|
idleSlot={listHeaderChrome.makeFilterSlot}
|
|
1342
1347
|
count={selectionCount}
|
|
1343
1348
|
onCancel={handleSelectionCancel}
|
|
1344
|
-
onDelete={
|
|
1345
|
-
|
|
1346
|
-
canOrganize
|
|
1347
|
-
? () =>
|
|
1348
|
-
isDesktop
|
|
1349
|
-
? setOrganizeOpen(true)
|
|
1350
|
-
: setMobileOrganizeEntry("select-similar")
|
|
1351
|
-
: undefined
|
|
1349
|
+
onDelete={
|
|
1350
|
+
canOpenWizard ? () => startWizard("delete") : handleSelectionDelete
|
|
1352
1351
|
}
|
|
1352
|
+
onMove={canOpenWizard ? () => startWizard("move") : undefined}
|
|
1353
|
+
moveSlot={selectionMoveSlot}
|
|
1354
|
+
onOrganize={canOpenWizard ? () => startWizard("organize") : undefined}
|
|
1353
1355
|
onJunk={
|
|
1354
|
-
junkMailboxId && junkMailboxId !== mailboxId
|
|
1355
|
-
?
|
|
1356
|
+
junkMailboxId && junkMailboxId !== mailboxId
|
|
1357
|
+
? canOpenWizard
|
|
1358
|
+
? () => startWizard("junk")
|
|
1359
|
+
: !moveDisabledHint
|
|
1360
|
+
? handleJunk
|
|
1361
|
+
: undefined
|
|
1356
1362
|
: undefined
|
|
1357
1363
|
}
|
|
1358
|
-
onMarkRead={
|
|
1359
|
-
|
|
1360
|
-
canOrganize && !isDesktop
|
|
1361
|
-
? () => setMobileOrganizeEntry("something-else")
|
|
1362
|
-
: undefined
|
|
1364
|
+
onMarkRead={
|
|
1365
|
+
canOpenWizard ? () => startWizard("markRead") : handleMarkAsRead
|
|
1363
1366
|
}
|
|
1364
|
-
moveSlot={selectionMoveSlot}
|
|
1365
1367
|
overflowSlot={
|
|
1366
1368
|
accountId && mailboxId && selectedCount > 0 && labels.length > 0 ? (
|
|
1367
1369
|
<LabelApplyTrigger
|
|
@@ -1516,7 +1518,6 @@ export const MessageList = ({
|
|
|
1516
1518
|
isDesktop={isDesktop}
|
|
1517
1519
|
hideHeader={hideHeader}
|
|
1518
1520
|
selectionBar={activeSelectionBar}
|
|
1519
|
-
paneOverlay={mobileOrganizeFlow}
|
|
1520
1521
|
listBody={
|
|
1521
1522
|
listHeaderChrome.searchResults ??
|
|
1522
1523
|
(listState === "ready" ? virtualBody : undefined)
|
|
@@ -1539,15 +1540,14 @@ export const MessageList = ({
|
|
|
1539
1540
|
onConfirm={handleConfirmDelete}
|
|
1540
1541
|
onCancel={handleCancelDelete}
|
|
1541
1542
|
/>
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
)}
|
|
1543
|
+
<SelectionWizardHost
|
|
1544
|
+
verb={wizardVerb}
|
|
1545
|
+
accountId={accountId}
|
|
1546
|
+
mailboxId={mailboxId}
|
|
1547
|
+
selection={wizardSelection}
|
|
1548
|
+
crossAccount={moveDisabledHint !== undefined}
|
|
1549
|
+
onFinished={exitSelection}
|
|
1550
|
+
/>
|
|
1551
1551
|
</>
|
|
1552
1552
|
);
|
|
1553
1553
|
};
|