@remit/web-client 0.0.90 → 0.0.92
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 +19 -15
- package/src/components/mail/DailyBrief.tsx +70 -89
- package/src/components/mail/FlaggedList.tsx +58 -50
- package/src/components/mail/LabelApplyTrigger.tsx +11 -5
- package/src/components/mail/MailListHeader.tsx +165 -85
- package/src/components/mail/MailViewChrome.tsx +7 -6
- package/src/components/mail/MessageList.selection.test.ts +42 -37
- package/src/components/mail/MessageList.tsx +70 -98
- package/src/components/mail/MoveToTrigger.tsx +1 -1
- package/src/components/mail/SelectionWizardHost.tsx +191 -0
- package/src/components/mail/ThreadListInteraction.test.ts +8 -2
- package/src/components/mail/ThreadListInteraction.tsx +56 -24
- package/src/hooks/useInitialSyncProgress.render.test.ts +11 -5
- package/src/lib/list-header-chrome.ts +50 -0
- package/src/lib/mail-context.ts +9 -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 +116 -0
- package/src/routes/mail.tsx +11 -0
- package/src/components/mail/SelectionToolbar.render.test.ts +0 -324
- package/src/components/mail/SelectionToolbar.stories.tsx +0 -199
- package/src/components/mail/SelectionToolbar.tsx +0 -274
- package/src/lib/selection-sheet-mode.test.ts +0 -105
- package/src/lib/selection-sheet-mode.ts +0 -49
|
@@ -5,8 +5,7 @@ import {
|
|
|
5
5
|
type MessageListFilter,
|
|
6
6
|
MessageListLoadingMore,
|
|
7
7
|
MessageListPane,
|
|
8
|
-
|
|
9
|
-
SelectionSheet,
|
|
8
|
+
SelectionTopBar,
|
|
10
9
|
} from "@remit/ui";
|
|
11
10
|
import { useBlocker, useNavigate } from "@tanstack/react-router";
|
|
12
11
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
@@ -22,6 +21,7 @@ import {
|
|
|
22
21
|
useEscalatedActions,
|
|
23
22
|
} from "@/hooks/useEscalatedActions";
|
|
24
23
|
import { useFollowFocusOpen } from "@/hooks/useFollowFocusOpen";
|
|
24
|
+
import { useLabelList } from "@/hooks/useLabels";
|
|
25
25
|
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
26
26
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
27
27
|
import {
|
|
@@ -46,19 +46,17 @@ import {
|
|
|
46
46
|
} from "@/lib/escalation-label";
|
|
47
47
|
import { formatDeleteToTrashTitle, formatNumber } from "@/lib/format";
|
|
48
48
|
import { tabStopId } from "@/lib/list-focus";
|
|
49
|
+
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
49
50
|
import {
|
|
50
51
|
deriveIsMultiSelectMode,
|
|
51
52
|
shouldExitSelectionOnNavigate,
|
|
52
53
|
} from "@/lib/selection-mode";
|
|
53
|
-
import {
|
|
54
|
-
resolveSelectionSheetMode,
|
|
55
|
-
shouldShowSelectionSheet,
|
|
56
|
-
} from "@/lib/selection-sheet-mode";
|
|
57
54
|
import { cn } from "@/lib/utils";
|
|
55
|
+
import { useWizardStepValue } from "@/lib/wizard-history";
|
|
56
|
+
import { LabelApplyTrigger } from "./LabelApplyTrigger";
|
|
58
57
|
import { MoveToTrigger } from "./MoveToTrigger";
|
|
59
58
|
import { MobileOrganizeFlow } from "./organize/MobileOrganizeFlow";
|
|
60
59
|
import { OrganizeDialog } from "./organize/OrganizeDialog";
|
|
61
|
-
import { SelectionToolbar } from "./SelectionToolbar";
|
|
62
60
|
import { SwipeableMessageRow } from "./SwipeableMessageRow";
|
|
63
61
|
|
|
64
62
|
/**
|
|
@@ -235,6 +233,7 @@ export const MessageList = ({
|
|
|
235
233
|
const parentRef = useRef<HTMLDivElement>(null);
|
|
236
234
|
const navigate = useNavigate();
|
|
237
235
|
const isDesktop = useIsDesktop();
|
|
236
|
+
const wizardStep = useWizardStepValue();
|
|
238
237
|
const [organizeOpen, setOrganizeOpen] = useState(false);
|
|
239
238
|
// Mobile only: which selection-sheet entry opened the guided organize flow
|
|
240
239
|
// (`null` when closed). Desktop uses `organizeOpen` + `OrganizeDialog`
|
|
@@ -243,6 +242,8 @@ export const MessageList = ({
|
|
|
243
242
|
"select-similar" | "something-else" | null
|
|
244
243
|
>(null);
|
|
245
244
|
const isSearching = !!searchQuery?.trim();
|
|
245
|
+
const listHeaderChrome = useListHeaderChrome();
|
|
246
|
+
const { labels } = useLabelList(accountId);
|
|
246
247
|
|
|
247
248
|
// Roving focus cursor (#429): the keyboard "where am I" pointer, distinct
|
|
248
249
|
// from the open thread (`selectedMessageId` in the URL). j/k move this
|
|
@@ -1061,12 +1062,14 @@ export const MessageList = ({
|
|
|
1061
1062
|
// — is never blocked, and the blocker is off entirely with nothing selected.
|
|
1062
1063
|
useBlocker({
|
|
1063
1064
|
shouldBlockFn: ({ action }) => {
|
|
1064
|
-
if (!shouldExitSelectionOnNavigate(action, hasSelection))
|
|
1065
|
+
if (!shouldExitSelectionOnNavigate(action, hasSelection, wizardStep)) {
|
|
1066
|
+
return false;
|
|
1067
|
+
}
|
|
1065
1068
|
exitSelection();
|
|
1066
1069
|
return true;
|
|
1067
1070
|
},
|
|
1068
1071
|
enableBeforeUnload: false,
|
|
1069
|
-
disabled: !hasSelection,
|
|
1072
|
+
disabled: !hasSelection || wizardStep !== undefined,
|
|
1070
1073
|
});
|
|
1071
1074
|
|
|
1072
1075
|
// Load more when scrolling near the bottom
|
|
@@ -1282,37 +1285,15 @@ export const MessageList = ({
|
|
|
1282
1285
|
}
|
|
1283
1286
|
: undefined;
|
|
1284
1287
|
|
|
1285
|
-
// The
|
|
1286
|
-
//
|
|
1287
|
-
const
|
|
1288
|
+
// The bar shows one notice at a time, so the cross-account move restriction
|
|
1289
|
+
// rides in behind the escalation states.
|
|
1290
|
+
const selectionNotice =
|
|
1288
1291
|
escalationNotice ??
|
|
1289
1292
|
(moveDisabledHint
|
|
1290
1293
|
? { tone: "warning" as const, text: moveDisabledHint }
|
|
1291
1294
|
: undefined);
|
|
1292
1295
|
|
|
1293
|
-
|
|
1294
|
-
// machinery. `running` (a chunked delete/move/mark-read) and `counting` (a
|
|
1295
|
-
// predicate still paging) replace the idle quick actions with status and
|
|
1296
|
-
// progress; `escalated` keeps the verbs over the whole predicate.
|
|
1297
|
-
const mobileMode = resolveSelectionSheetMode({
|
|
1298
|
-
isRunning: escalation.isRunning,
|
|
1299
|
-
isCounting: escalation.phase.kind === "counting",
|
|
1300
|
-
isEscalated: escalation.phase.kind === "escalated",
|
|
1301
|
-
});
|
|
1302
|
-
|
|
1303
|
-
// The sheet is the sole mobile selection surface. It rises at 2+ selected
|
|
1304
|
-
// (the prototype threshold) or in any non-idle escalation state — a single
|
|
1305
|
-
// selected row enters selection mode without raising it, matching today.
|
|
1306
|
-
const showMobileSheet =
|
|
1307
|
-
!isDesktop &&
|
|
1308
|
-
isMultiSelectMode &&
|
|
1309
|
-
shouldShowSelectionSheet(
|
|
1310
|
-
selectionCount,
|
|
1311
|
-
mobileMode,
|
|
1312
|
-
mobileNotice !== undefined,
|
|
1313
|
-
);
|
|
1314
|
-
|
|
1315
|
-
const mobileMoveSlot =
|
|
1296
|
+
const selectionMoveSlot =
|
|
1316
1297
|
(onMoveMessages || escalation.phase.kind === "escalated") &&
|
|
1317
1298
|
accountId &&
|
|
1318
1299
|
mailboxId ? (
|
|
@@ -1325,9 +1306,8 @@ export const MessageList = ({
|
|
|
1325
1306
|
/>
|
|
1326
1307
|
) : undefined;
|
|
1327
1308
|
|
|
1328
|
-
// The guided organize flow (issue #211)
|
|
1329
|
-
// ancestor
|
|
1330
|
-
// stays mounted underneath as the flow's dimmed backdrop.
|
|
1309
|
+
// The guided organize flow (issue #211) covers the pane, which is its
|
|
1310
|
+
// positioned ancestor.
|
|
1331
1311
|
const mobileOrganizeFlow =
|
|
1332
1312
|
mobileOrganizeEntry && accountId && !isDesktop && selectedCount > 0 ? (
|
|
1333
1313
|
<MobileOrganizeFlow
|
|
@@ -1343,72 +1323,66 @@ export const MessageList = ({
|
|
|
1343
1323
|
/>
|
|
1344
1324
|
) : undefined;
|
|
1345
1325
|
|
|
1346
|
-
|
|
1347
|
-
|
|
1326
|
+
// Organize acts on the materialized selection within one account, so it is
|
|
1327
|
+
// withdrawn the moment the selection escalates to a predicate or a run
|
|
1328
|
+
// takes over — both of which name themselves through `statusLabel`.
|
|
1329
|
+
const canOrganize =
|
|
1330
|
+
!!accountId && !!mailboxId && !moveDisabledHint && !selectionStatusLabel;
|
|
1331
|
+
|
|
1332
|
+
// One selection surface at every width, always mounted: it is the list
|
|
1333
|
+
// header. With nothing ticked it names the mailbox and carries the header's
|
|
1334
|
+
// own chrome; from the first ticked row the count and the verbs take that
|
|
1335
|
+
// title's place, and the escalation states (issue #212) — the offer,
|
|
1336
|
+
// counting, the escalated predicate, a chunked run's progress — ride on the
|
|
1337
|
+
// same bar rather than a second one.
|
|
1338
|
+
const activeSelectionBar = (
|
|
1339
|
+
<SelectionTopBar
|
|
1340
|
+
title={listHeaderChrome.title || listTitle}
|
|
1341
|
+
navSlot={listHeaderChrome.navSlot}
|
|
1342
|
+
titleMeta={listHeaderChrome.titleMeta}
|
|
1343
|
+
searchSlot={listHeaderChrome.searchSlot}
|
|
1344
|
+
searchField={listHeaderChrome.searchField}
|
|
1345
|
+
idleSlot={listHeaderChrome.makeFilterSlot}
|
|
1348
1346
|
count={selectionCount}
|
|
1349
|
-
mode={mobileMode}
|
|
1350
1347
|
onCancel={handleSelectionCancel}
|
|
1351
1348
|
onDelete={handleSelectionDelete}
|
|
1349
|
+
onOrganize={
|
|
1350
|
+
canOrganize
|
|
1351
|
+
? () =>
|
|
1352
|
+
isDesktop
|
|
1353
|
+
? setOrganizeOpen(true)
|
|
1354
|
+
: setMobileOrganizeEntry("select-similar")
|
|
1355
|
+
: undefined
|
|
1356
|
+
}
|
|
1352
1357
|
onJunk={
|
|
1353
1358
|
junkMailboxId && junkMailboxId !== mailboxId && !moveDisabledHint
|
|
1354
1359
|
? handleJunk
|
|
1355
1360
|
: undefined
|
|
1356
1361
|
}
|
|
1357
1362
|
onMarkRead={handleMarkAsRead}
|
|
1358
|
-
onSelectSimilar={
|
|
1359
|
-
accountId ? () => setMobileOrganizeEntry("select-similar") : undefined
|
|
1360
|
-
}
|
|
1361
1363
|
onSomethingElse={
|
|
1362
|
-
|
|
1364
|
+
canOrganize && !isDesktop
|
|
1365
|
+
? () => setMobileOrganizeEntry("something-else")
|
|
1366
|
+
: undefined
|
|
1367
|
+
}
|
|
1368
|
+
moveSlot={selectionMoveSlot}
|
|
1369
|
+
overflowSlot={
|
|
1370
|
+
accountId && mailboxId && selectedCount > 0 && labels.length > 0 ? (
|
|
1371
|
+
<LabelApplyTrigger
|
|
1372
|
+
accountId={accountId}
|
|
1373
|
+
mailboxId={mailboxId}
|
|
1374
|
+
messageIds={Array.from(selectedIds)}
|
|
1375
|
+
/>
|
|
1376
|
+
) : undefined
|
|
1363
1377
|
}
|
|
1364
|
-
moveSlot={mobileMoveSlot}
|
|
1365
1378
|
isBusy={selectionIsBusy}
|
|
1379
|
+
isCounting={escalation.phase.kind === "counting"}
|
|
1366
1380
|
selectAll={selectionSelectAll}
|
|
1367
1381
|
statusLabel={selectionStatusLabel}
|
|
1368
1382
|
progress={selectionProgress}
|
|
1369
|
-
notice={
|
|
1383
|
+
notice={selectionNotice}
|
|
1370
1384
|
/>
|
|
1371
|
-
)
|
|
1372
|
-
|
|
1373
|
-
// Both mobile overlays share the pane's positioned ancestor via the sheet
|
|
1374
|
-
// slot: the peeking sheet, and the guided organize flow above it.
|
|
1375
|
-
const mobileSelectionSurface =
|
|
1376
|
-
mobileSelectionSheet || mobileOrganizeFlow ? (
|
|
1377
|
-
<>
|
|
1378
|
-
{mobileSelectionSheet}
|
|
1379
|
-
{mobileOrganizeFlow}
|
|
1380
|
-
</>
|
|
1381
|
-
) : undefined;
|
|
1382
|
-
|
|
1383
|
-
// Desktop selection toolbar replaces the pane header when any rows are
|
|
1384
|
-
// selected. It gains the same search-escalation states the mobile sheet
|
|
1385
|
-
// carries (issue #212) — the offer, counting, the escalated predicate and a
|
|
1386
|
-
// chunked run's progress — from the shared derivations above; the
|
|
1387
|
-
// cross-account move hint stays inline (the toolbar has a slot for it).
|
|
1388
|
-
const desktopSelectionBar =
|
|
1389
|
-
hasSelection && isDesktop ? (
|
|
1390
|
-
<SelectionToolbar
|
|
1391
|
-
selectedCount={selectionCount}
|
|
1392
|
-
onDelete={handleSelectionDelete}
|
|
1393
|
-
onClearSelection={handleSelectionCancel}
|
|
1394
|
-
onMarkAsRead={handleMarkAsRead}
|
|
1395
|
-
onMove={onMoveMessages ? handleMoveSelected : undefined}
|
|
1396
|
-
onOrganize={() => setOrganizeOpen(true)}
|
|
1397
|
-
isDeleting={isDeleting}
|
|
1398
|
-
isMoving={isMoving}
|
|
1399
|
-
accountId={accountId}
|
|
1400
|
-
currentMailboxId={mailboxId}
|
|
1401
|
-
selectedMessageIds={Array.from(selectedIds)}
|
|
1402
|
-
moveDisabledHint={moveDisabledHint}
|
|
1403
|
-
selectAll={escalationEnabled ? selectionSelectAll : undefined}
|
|
1404
|
-
statusLabel={selectionStatusLabel}
|
|
1405
|
-
isCounting={escalation.phase.kind === "counting"}
|
|
1406
|
-
progress={selectionProgress}
|
|
1407
|
-
notice={escalationNotice}
|
|
1408
|
-
/>
|
|
1409
|
-
) : undefined;
|
|
1410
|
-
|
|
1411
|
-
const activeSelectionBar = desktopSelectionBar;
|
|
1385
|
+
);
|
|
1412
1386
|
|
|
1413
1387
|
// Roving tabindex: exactly one row is in the tab order, so Tab moves focus
|
|
1414
1388
|
// into the list at the cursor and Shift+Tab moves back out to the side panel
|
|
@@ -1440,13 +1414,6 @@ export const MessageList = ({
|
|
|
1440
1414
|
aria-multiselectable
|
|
1441
1415
|
aria-label={listTitle}
|
|
1442
1416
|
className="flex-1 overflow-y-auto"
|
|
1443
|
-
// Pad the list so its last rows clear the peeking teaser (~56px)
|
|
1444
|
-
// rather than hiding behind it.
|
|
1445
|
-
style={
|
|
1446
|
-
showMobileSheet
|
|
1447
|
-
? { paddingBottom: SELECTION_SHEET_TEASER_HEIGHT }
|
|
1448
|
-
: undefined
|
|
1449
|
-
}
|
|
1450
1417
|
>
|
|
1451
1418
|
{/* Virtualizer scaffolding — presentational so the listbox sees the
|
|
1452
1419
|
rows as its options rather than these positioning wrappers. */}
|
|
@@ -1531,7 +1498,9 @@ export const MessageList = ({
|
|
|
1531
1498
|
listMeta={listMeta}
|
|
1532
1499
|
sections={sections}
|
|
1533
1500
|
flatList
|
|
1534
|
-
|
|
1501
|
+
// The results panel is a body, not a list state: it stands in for the
|
|
1502
|
+
// rows and must not fall behind a skeleton while the query re-keys.
|
|
1503
|
+
listState={listHeaderChrome.searchResults ? "ready" : listState}
|
|
1535
1504
|
searchQuery={isSearching ? searchQuery : undefined}
|
|
1536
1505
|
listFilter={listFilter}
|
|
1537
1506
|
listScopeLabel={listScopeLabel}
|
|
@@ -1551,8 +1520,11 @@ export const MessageList = ({
|
|
|
1551
1520
|
isDesktop={isDesktop}
|
|
1552
1521
|
hideHeader={hideHeader}
|
|
1553
1522
|
selectionBar={activeSelectionBar}
|
|
1554
|
-
|
|
1555
|
-
listBody={
|
|
1523
|
+
paneOverlay={mobileOrganizeFlow}
|
|
1524
|
+
listBody={
|
|
1525
|
+
listHeaderChrome.searchResults ??
|
|
1526
|
+
(listState === "ready" ? virtualBody : undefined)
|
|
1527
|
+
}
|
|
1556
1528
|
/>
|
|
1557
1529
|
<ConfirmDialog
|
|
1558
1530
|
isOpen={pendingDelete !== null}
|
|
@@ -180,7 +180,7 @@ export const MoveToTrigger = ({
|
|
|
180
180
|
title={disabledHint}
|
|
181
181
|
className={variant === "icon-only" ? ICON_TRIGGER : COMPACT_TRIGGER}
|
|
182
182
|
>
|
|
183
|
-
<FolderInput className="size-4" />
|
|
183
|
+
<FolderInput className={variant === "icon-only" ? "size-5" : "size-4"} />
|
|
184
184
|
{variant === "compact" && <span className="hidden sm:inline">Move</span>}
|
|
185
185
|
</button>
|
|
186
186
|
);
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ClauseDraft,
|
|
3
|
+
type ClauseEditState,
|
|
4
|
+
type MatchCount,
|
|
5
|
+
type MatchMode,
|
|
6
|
+
type RunState,
|
|
7
|
+
SelectionWizard,
|
|
8
|
+
type StepId,
|
|
9
|
+
stepBlockedReason,
|
|
10
|
+
stepIndex,
|
|
11
|
+
stepsFor,
|
|
12
|
+
type Verb,
|
|
13
|
+
type WizardDraft,
|
|
14
|
+
type WizardMessage,
|
|
15
|
+
} from "@remit/ui";
|
|
16
|
+
import { useCallback, useState } from "react";
|
|
17
|
+
import { useWizardStep } from "@/lib/wizard-history";
|
|
18
|
+
|
|
19
|
+
const OPENING_STEP: StepId = "match";
|
|
20
|
+
const UNCOUNTED: MatchCount = { status: "uncounted" };
|
|
21
|
+
const EMPTY_DRAFT: WizardDraft = { clauses: [], matchOperator: "all" };
|
|
22
|
+
|
|
23
|
+
/** How the commit ended, once something commits it (#483). */
|
|
24
|
+
export interface WizardOutcome {
|
|
25
|
+
state: RunState;
|
|
26
|
+
matched: number;
|
|
27
|
+
applied: number;
|
|
28
|
+
failures: readonly WizardMessage[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Nothing has been sent anywhere, so nothing can have been done. */
|
|
32
|
+
const NOTHING_COMMITTED: WizardOutcome = {
|
|
33
|
+
state: "commitFailed",
|
|
34
|
+
matched: 0,
|
|
35
|
+
applied: 0,
|
|
36
|
+
failures: [],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export interface SelectionWizardHostProps {
|
|
40
|
+
verb: Verb;
|
|
41
|
+
selectedCount: number;
|
|
42
|
+
outcome?: WizardOutcome;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function SelectionWizardHost({
|
|
46
|
+
verb,
|
|
47
|
+
selectedCount,
|
|
48
|
+
outcome = NOTHING_COMMITTED,
|
|
49
|
+
}: SelectionWizardHostProps) {
|
|
50
|
+
const { step, goToStep, goBack, closeWizard } = useWizardStep(OPENING_STEP);
|
|
51
|
+
const [mode, setMode] = useState<MatchMode>("selected");
|
|
52
|
+
const [draft, setDraft] = useState<WizardDraft>(EMPTY_DRAFT);
|
|
53
|
+
const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>(
|
|
54
|
+
undefined,
|
|
55
|
+
);
|
|
56
|
+
const [nudgedStep, setNudgedStep] = useState<StepId | undefined>(undefined);
|
|
57
|
+
|
|
58
|
+
const startAddClause = useCallback(() => {
|
|
59
|
+
setClauseEdit({ mode: "add", draft: { field: "From", value: "" } });
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
const startEditClause = useCallback(
|
|
63
|
+
(clauseId: string) => {
|
|
64
|
+
const clause = draft.clauses.find((held) => held.id === clauseId);
|
|
65
|
+
if (!clause) return;
|
|
66
|
+
setClauseEdit({
|
|
67
|
+
mode: "edit",
|
|
68
|
+
clauseId,
|
|
69
|
+
draft: { field: clause.field, value: clause.value },
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
[draft.clauses],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const removeClause = useCallback((clauseId: string) => {
|
|
76
|
+
setDraft((held) => ({
|
|
77
|
+
...held,
|
|
78
|
+
clauses: held.clauses.filter((clause) => clause.id !== clauseId),
|
|
79
|
+
}));
|
|
80
|
+
}, []);
|
|
81
|
+
|
|
82
|
+
const changeClauseDraft = useCallback((next: ClauseDraft) => {
|
|
83
|
+
setClauseEdit((edit) => edit && { ...edit, draft: next });
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
const submitClause = useCallback(() => {
|
|
87
|
+
setClauseEdit((edit) => {
|
|
88
|
+
if (!edit) return undefined;
|
|
89
|
+
setDraft((held) => ({
|
|
90
|
+
...held,
|
|
91
|
+
clauses: edit.clauseId
|
|
92
|
+
? held.clauses.map((clause) =>
|
|
93
|
+
clause.id === edit.clauseId
|
|
94
|
+
? { ...clause, ...edit.draft }
|
|
95
|
+
: clause,
|
|
96
|
+
)
|
|
97
|
+
: [...held.clauses, { id: crypto.randomUUID(), ...edit.draft }],
|
|
98
|
+
}));
|
|
99
|
+
return undefined;
|
|
100
|
+
});
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
if (!step) return null;
|
|
104
|
+
|
|
105
|
+
const steps = stepsFor({ verb, mode, scope: draft.scope });
|
|
106
|
+
// The step the screens are on, which is the held one only while the answers
|
|
107
|
+
// still hold it. Reading the URL's step here and the resolved one on screen
|
|
108
|
+
// is how a footer comes to name a screen nobody is looking at.
|
|
109
|
+
const current = steps[stepIndex(steps, step)];
|
|
110
|
+
const blockedReason = stepBlockedReason(current, draft, UNCOUNTED);
|
|
111
|
+
const sample = {
|
|
112
|
+
messages: [],
|
|
113
|
+
count: UNCOUNTED,
|
|
114
|
+
label: "Messages this covers",
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const advance = () => {
|
|
118
|
+
if (blockedReason) {
|
|
119
|
+
setNudgedStep(current);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const next = steps[stepIndex(steps, current) + 1];
|
|
123
|
+
if (next) goToStep(next);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<SelectionWizard
|
|
128
|
+
verb={verb}
|
|
129
|
+
steps={steps}
|
|
130
|
+
step={current}
|
|
131
|
+
blockedReason={blockedReason}
|
|
132
|
+
nudged={nudgedStep === current}
|
|
133
|
+
onBack={goBack}
|
|
134
|
+
onExit={() => closeWizard(steps, current)}
|
|
135
|
+
onContinue={advance}
|
|
136
|
+
onCommit={() => goToStep("run")}
|
|
137
|
+
match={{
|
|
138
|
+
selectedCount,
|
|
139
|
+
mode,
|
|
140
|
+
onModeChange: setMode,
|
|
141
|
+
onSemanticFallback: () => setMode("properties"),
|
|
142
|
+
sample,
|
|
143
|
+
}}
|
|
144
|
+
properties={{
|
|
145
|
+
clauses: draft.clauses,
|
|
146
|
+
matchOperator: draft.matchOperator,
|
|
147
|
+
onMatchOperatorChange: (matchOperator) =>
|
|
148
|
+
setDraft((held) => ({ ...held, matchOperator })),
|
|
149
|
+
clauseEdit,
|
|
150
|
+
onStartAddClause: startAddClause,
|
|
151
|
+
onStartEditClause: startEditClause,
|
|
152
|
+
onRemoveClause: removeClause,
|
|
153
|
+
onChangeDraft: changeClauseDraft,
|
|
154
|
+
onSubmitClause: submitClause,
|
|
155
|
+
onCancelClause: () => setClauseEdit(undefined),
|
|
156
|
+
sample,
|
|
157
|
+
}}
|
|
158
|
+
rule={{
|
|
159
|
+
draft,
|
|
160
|
+
onScopeChange: (scope) => setDraft((held) => ({ ...held, scope })),
|
|
161
|
+
onUntilChange: (until) => setDraft((held) => ({ ...held, until })),
|
|
162
|
+
}}
|
|
163
|
+
name={{
|
|
164
|
+
name: draft.name ?? "",
|
|
165
|
+
onNameChange: (name) => setDraft((held) => ({ ...held, name })),
|
|
166
|
+
nudged: nudgedStep === current,
|
|
167
|
+
}}
|
|
168
|
+
review={{
|
|
169
|
+
verb,
|
|
170
|
+
mode,
|
|
171
|
+
selectedCount,
|
|
172
|
+
clauses: draft.clauses,
|
|
173
|
+
matchOperator: draft.matchOperator,
|
|
174
|
+
scope: draft.scope,
|
|
175
|
+
until: draft.until,
|
|
176
|
+
ruleName: draft.name,
|
|
177
|
+
sample,
|
|
178
|
+
}}
|
|
179
|
+
run={{
|
|
180
|
+
state: outcome.state,
|
|
181
|
+
verb,
|
|
182
|
+
scope: draft.scope,
|
|
183
|
+
matched: outcome.matched,
|
|
184
|
+
applied: outcome.applied,
|
|
185
|
+
failures: outcome.failures,
|
|
186
|
+
onRetry: goBack,
|
|
187
|
+
onDismiss: () => closeWizard(steps, current),
|
|
188
|
+
}}
|
|
189
|
+
/>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -69,6 +69,7 @@ function mountList(options: {
|
|
|
69
69
|
initialIds: string[];
|
|
70
70
|
onDeleteMessages?: (ids: string[]) => void;
|
|
71
71
|
}) {
|
|
72
|
+
const onDeleteMessages = options.onDeleteMessages ?? (() => undefined);
|
|
72
73
|
const commandsRef = createRef<MessageListCommands | null>();
|
|
73
74
|
let setIds: ((ids: string[]) => void) | undefined;
|
|
74
75
|
const Harness = () => {
|
|
@@ -79,7 +80,7 @@ function mountList(options: {
|
|
|
79
80
|
{
|
|
80
81
|
selectedMessageId: undefined,
|
|
81
82
|
onOpen: () => undefined,
|
|
82
|
-
onDeleteMessages
|
|
83
|
+
onDeleteMessages,
|
|
83
84
|
commandsRef,
|
|
84
85
|
},
|
|
85
86
|
...rowElements(ids),
|
|
@@ -222,7 +223,12 @@ function mountSelectableList(initialIds: string[]) {
|
|
|
222
223
|
setIds = set;
|
|
223
224
|
return createElement(
|
|
224
225
|
ThreadListInteraction,
|
|
225
|
-
{
|
|
226
|
+
{
|
|
227
|
+
selectedMessageId: undefined,
|
|
228
|
+
onOpen: () => undefined,
|
|
229
|
+
onDeleteMessages: () => undefined,
|
|
230
|
+
commandsRef,
|
|
231
|
+
},
|
|
226
232
|
...rowElements(ids),
|
|
227
233
|
createElement(Probe, { key: "probe" }),
|
|
228
234
|
);
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* are not rendered: focus stops moving, the highlight disappears, and the next
|
|
17
17
|
* verb acts on a message the user cannot see.
|
|
18
18
|
*/
|
|
19
|
+
import { SelectionTopBar } from "@remit/ui";
|
|
19
20
|
import {
|
|
20
21
|
createContext,
|
|
21
22
|
type ReactNode,
|
|
@@ -34,9 +35,10 @@ import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
|
34
35
|
import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
|
|
35
36
|
import { formatDeleteToTrashTitle } from "@/lib/format";
|
|
36
37
|
import { tabStopId } from "@/lib/list-focus";
|
|
38
|
+
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
39
|
+
import { useMailContext } from "@/lib/mail-context";
|
|
37
40
|
import type { MessageListCommands } from "./MessageList";
|
|
38
41
|
import type { MessageRowSelection } from "./MessageRow";
|
|
39
|
-
import { SelectionToolbar } from "./SelectionToolbar";
|
|
40
42
|
|
|
41
43
|
interface ThreadRowInteraction {
|
|
42
44
|
focused: boolean;
|
|
@@ -142,7 +144,7 @@ interface ThreadListInteractionProps {
|
|
|
142
144
|
/** Opens a row — the same navigation a click performs. */
|
|
143
145
|
onOpen: (messageId: string, options?: OpenMessageOptions) => void;
|
|
144
146
|
/** Deletes a set of messages. Absent disables the delete key for this list. */
|
|
145
|
-
onDeleteMessages
|
|
147
|
+
onDeleteMessages: (messageIds: string[]) => void;
|
|
146
148
|
isDeleting?: boolean;
|
|
147
149
|
commandsRef?: RefObject<MessageListCommands | null>;
|
|
148
150
|
onTriageContextChange?: (context: TriageContextUpdate) => void;
|
|
@@ -185,6 +187,14 @@ export function ThreadListInteraction({
|
|
|
185
187
|
if (orderedIds.length > 0) toggleAll(orderedIds);
|
|
186
188
|
}, [orderedIds, toggleAll]);
|
|
187
189
|
|
|
190
|
+
// The selection wizard is mounted on the route, above this provider, and
|
|
191
|
+
// acts on these rows — so it reads the count from here rather than keeping
|
|
192
|
+
// one of its own (#477).
|
|
193
|
+
const { onSelectedCountChange } = useMailContext();
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
onSelectedCountChange(selectedCount);
|
|
196
|
+
}, [selectedCount, onSelectedCountChange]);
|
|
197
|
+
|
|
188
198
|
// A row that leaves the list — a chip filter, a collapsed section, a
|
|
189
199
|
// completed delete — cannot stay selected. Survivors keep their selection.
|
|
190
200
|
const { intersectWith } = selection;
|
|
@@ -241,14 +251,11 @@ export function ThreadListInteraction({
|
|
|
241
251
|
// the same contract the mailbox list's delete has.
|
|
242
252
|
const [pendingDelete, setPendingDelete] = useState<string[] | null>(null);
|
|
243
253
|
|
|
244
|
-
const requestDeleteIds = useCallback(
|
|
245
|
-
(ids
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
},
|
|
250
|
-
[onDeleteMessages],
|
|
251
|
-
);
|
|
254
|
+
const requestDeleteIds = useCallback((ids: string[]): boolean => {
|
|
255
|
+
if (ids.length === 0) return false;
|
|
256
|
+
setPendingDelete(ids);
|
|
257
|
+
return true;
|
|
258
|
+
}, []);
|
|
252
259
|
|
|
253
260
|
const requestDeleteSelection = useCallback(() => {
|
|
254
261
|
requestDeleteIds(Array.from(selectedIds));
|
|
@@ -272,7 +279,7 @@ export function ThreadListInteraction({
|
|
|
272
279
|
|
|
273
280
|
const confirmDelete = useCallback(() => {
|
|
274
281
|
if (pendingDelete === null) return;
|
|
275
|
-
onDeleteMessages
|
|
282
|
+
onDeleteMessages(pendingDelete);
|
|
276
283
|
setPendingDelete(null);
|
|
277
284
|
exitSelection();
|
|
278
285
|
}, [pendingDelete, onDeleteMessages, exitSelection]);
|
|
@@ -416,25 +423,35 @@ export function ThreadListInteraction({
|
|
|
416
423
|
}
|
|
417
424
|
|
|
418
425
|
interface ThreadListSelectionBarProps {
|
|
426
|
+
/** The view's own name, used until the enclosing header supplies one. */
|
|
427
|
+
title: string;
|
|
419
428
|
onMarkAsRead?: (messageIds: string[]) => void;
|
|
420
429
|
isDeleting?: boolean;
|
|
421
430
|
}
|
|
422
431
|
|
|
423
432
|
/**
|
|
424
|
-
*
|
|
433
|
+
* The starred list's header, which is also its selection bar — the same
|
|
434
|
+
* surface the mailbox list and the brief raise.
|
|
425
435
|
*
|
|
426
|
-
* Move is not offered here:
|
|
427
|
-
* picker needs one account and one source folder to be honest about where
|
|
428
|
-
* messages go. Delete and mark-read carry no such scope.
|
|
429
|
-
* that scope from the selection instead and mounts the mailbox list's full
|
|
430
|
-
* `SelectionToolbar` — see `DailyBrief`.
|
|
436
|
+
* Move is not offered here: starred mail spans accounts and mailboxes, and a
|
|
437
|
+
* move picker needs one account and one source folder to be honest about where
|
|
438
|
+
* the messages go. Delete and mark-read carry no such scope.
|
|
431
439
|
*/
|
|
432
440
|
export function ThreadListSelectionBar({
|
|
441
|
+
title,
|
|
433
442
|
onMarkAsRead,
|
|
434
443
|
isDeleting,
|
|
435
444
|
}: ThreadListSelectionBarProps) {
|
|
436
|
-
const
|
|
437
|
-
|
|
445
|
+
const chrome = useListHeaderChrome();
|
|
446
|
+
const {
|
|
447
|
+
selectedIds,
|
|
448
|
+
selectedCount,
|
|
449
|
+
exitSelection,
|
|
450
|
+
requestDeleteSelection,
|
|
451
|
+
orderedIds,
|
|
452
|
+
allSelected,
|
|
453
|
+
toggleAllLoaded,
|
|
454
|
+
} = useThreadListSelection();
|
|
438
455
|
|
|
439
456
|
const handleMarkAsRead = useCallback(() => {
|
|
440
457
|
onMarkAsRead?.(Array.from(selectedIds));
|
|
@@ -442,12 +459,27 @@ export function ThreadListSelectionBar({
|
|
|
442
459
|
}, [onMarkAsRead, selectedIds, exitSelection]);
|
|
443
460
|
|
|
444
461
|
return (
|
|
445
|
-
<
|
|
446
|
-
|
|
462
|
+
<SelectionTopBar
|
|
463
|
+
title={chrome.title || title}
|
|
464
|
+
navSlot={chrome.navSlot}
|
|
465
|
+
titleMeta={chrome.titleMeta}
|
|
466
|
+
searchSlot={chrome.searchSlot}
|
|
467
|
+
searchField={chrome.searchField}
|
|
468
|
+
idleSlot={chrome.makeFilterSlot}
|
|
469
|
+
count={selectedCount}
|
|
470
|
+
onCancel={exitSelection}
|
|
447
471
|
onDelete={requestDeleteSelection}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
472
|
+
onMarkRead={onMarkAsRead ? handleMarkAsRead : undefined}
|
|
473
|
+
isBusy={isDeleting}
|
|
474
|
+
selectAll={
|
|
475
|
+
orderedIds.length > 0
|
|
476
|
+
? {
|
|
477
|
+
checked: allSelected,
|
|
478
|
+
indeterminate: selectedCount > 0 && !allSelected,
|
|
479
|
+
onChange: toggleAllLoaded,
|
|
480
|
+
}
|
|
481
|
+
: undefined
|
|
482
|
+
}
|
|
451
483
|
/>
|
|
452
484
|
);
|
|
453
485
|
}
|
|
@@ -69,6 +69,9 @@ function Probe({
|
|
|
69
69
|
/** The account id in `/accounts/{id}/sync/status`. */
|
|
70
70
|
const accountOf = (path: string): string => path.split("/")[2] ?? "";
|
|
71
71
|
|
|
72
|
+
const progress = (dom: DomHarness): InitialSyncProgress =>
|
|
73
|
+
JSON.parse(dom.text());
|
|
74
|
+
|
|
72
75
|
const mount = async (
|
|
73
76
|
accountIds: string[],
|
|
74
77
|
answer: (accountId: string) => unknown,
|
|
@@ -77,14 +80,17 @@ const mount = async (
|
|
|
77
80
|
http = mockFetch((call) => answer(accountOf(call.path)));
|
|
78
81
|
harness = createDomHarness();
|
|
79
82
|
harness.renderApp(createElement(Probe, { accountIds, enabled }));
|
|
80
|
-
|
|
81
|
-
|
|
83
|
+
// Settle on the hook's own answer rather than on a fixed number of turns:
|
|
84
|
+
// a query that lands on a timer instead of a microtask needs real time to
|
|
85
|
+
// pass, and a loaded machine is where that difference shows up.
|
|
86
|
+
for (let turn = 0; turn < 20; turn += 1) {
|
|
87
|
+
await harness.flush();
|
|
88
|
+
if (progress(harness).resolved) break;
|
|
89
|
+
await harness.wait(1);
|
|
90
|
+
}
|
|
82
91
|
return harness;
|
|
83
92
|
};
|
|
84
93
|
|
|
85
|
-
const progress = (dom: DomHarness): InitialSyncProgress =>
|
|
86
|
-
JSON.parse(dom.text());
|
|
87
|
-
|
|
88
94
|
describe("isSyncingPhase", () => {
|
|
89
95
|
it("is true only for the phases a running sync round writes", () => {
|
|
90
96
|
assert.equal(isSyncingPhase("discovering_mailboxes"), true);
|