@remit/web-client 0.0.90 → 0.0.91
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 +34 -36
- package/src/components/mail/MessageList.tsx +64 -96
- package/src/components/mail/MoveToTrigger.tsx +1 -1
- package/src/components/mail/ThreadListInteraction.test.ts +8 -2
- package/src/components/mail/ThreadListInteraction.tsx +47 -24
- package/src/hooks/useInitialSyncProgress.render.test.ts +11 -5
- package/src/lib/list-header-chrome.ts +50 -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,16 @@ 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 { LabelApplyTrigger } from "./LabelApplyTrigger";
|
|
58
56
|
import { MoveToTrigger } from "./MoveToTrigger";
|
|
59
57
|
import { MobileOrganizeFlow } from "./organize/MobileOrganizeFlow";
|
|
60
58
|
import { OrganizeDialog } from "./organize/OrganizeDialog";
|
|
61
|
-
import { SelectionToolbar } from "./SelectionToolbar";
|
|
62
59
|
import { SwipeableMessageRow } from "./SwipeableMessageRow";
|
|
63
60
|
|
|
64
61
|
/**
|
|
@@ -243,6 +240,8 @@ export const MessageList = ({
|
|
|
243
240
|
"select-similar" | "something-else" | null
|
|
244
241
|
>(null);
|
|
245
242
|
const isSearching = !!searchQuery?.trim();
|
|
243
|
+
const listHeaderChrome = useListHeaderChrome();
|
|
244
|
+
const { labels } = useLabelList(accountId);
|
|
246
245
|
|
|
247
246
|
// Roving focus cursor (#429): the keyboard "where am I" pointer, distinct
|
|
248
247
|
// from the open thread (`selectedMessageId` in the URL). j/k move this
|
|
@@ -1282,37 +1281,15 @@ export const MessageList = ({
|
|
|
1282
1281
|
}
|
|
1283
1282
|
: undefined;
|
|
1284
1283
|
|
|
1285
|
-
// The
|
|
1286
|
-
//
|
|
1287
|
-
const
|
|
1284
|
+
// The bar shows one notice at a time, so the cross-account move restriction
|
|
1285
|
+
// rides in behind the escalation states.
|
|
1286
|
+
const selectionNotice =
|
|
1288
1287
|
escalationNotice ??
|
|
1289
1288
|
(moveDisabledHint
|
|
1290
1289
|
? { tone: "warning" as const, text: moveDisabledHint }
|
|
1291
1290
|
: undefined);
|
|
1292
1291
|
|
|
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 =
|
|
1292
|
+
const selectionMoveSlot =
|
|
1316
1293
|
(onMoveMessages || escalation.phase.kind === "escalated") &&
|
|
1317
1294
|
accountId &&
|
|
1318
1295
|
mailboxId ? (
|
|
@@ -1325,9 +1302,8 @@ export const MessageList = ({
|
|
|
1325
1302
|
/>
|
|
1326
1303
|
) : undefined;
|
|
1327
1304
|
|
|
1328
|
-
// The guided organize flow (issue #211)
|
|
1329
|
-
// ancestor
|
|
1330
|
-
// stays mounted underneath as the flow's dimmed backdrop.
|
|
1305
|
+
// The guided organize flow (issue #211) covers the pane, which is its
|
|
1306
|
+
// positioned ancestor.
|
|
1331
1307
|
const mobileOrganizeFlow =
|
|
1332
1308
|
mobileOrganizeEntry && accountId && !isDesktop && selectedCount > 0 ? (
|
|
1333
1309
|
<MobileOrganizeFlow
|
|
@@ -1343,72 +1319,66 @@ export const MessageList = ({
|
|
|
1343
1319
|
/>
|
|
1344
1320
|
) : undefined;
|
|
1345
1321
|
|
|
1346
|
-
|
|
1347
|
-
|
|
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;
|
|
1327
|
+
|
|
1328
|
+
// One selection surface at every width, always mounted: it is the list
|
|
1329
|
+
// header. With nothing ticked it names the mailbox and carries the header's
|
|
1330
|
+
// own chrome; from the first ticked row the count and the verbs take that
|
|
1331
|
+
// title's place, and the escalation states (issue #212) — the offer,
|
|
1332
|
+
// counting, the escalated predicate, a chunked run's progress — ride on the
|
|
1333
|
+
// same bar rather than a second one.
|
|
1334
|
+
const activeSelectionBar = (
|
|
1335
|
+
<SelectionTopBar
|
|
1336
|
+
title={listHeaderChrome.title || listTitle}
|
|
1337
|
+
navSlot={listHeaderChrome.navSlot}
|
|
1338
|
+
titleMeta={listHeaderChrome.titleMeta}
|
|
1339
|
+
searchSlot={listHeaderChrome.searchSlot}
|
|
1340
|
+
searchField={listHeaderChrome.searchField}
|
|
1341
|
+
idleSlot={listHeaderChrome.makeFilterSlot}
|
|
1348
1342
|
count={selectionCount}
|
|
1349
|
-
mode={mobileMode}
|
|
1350
1343
|
onCancel={handleSelectionCancel}
|
|
1351
1344
|
onDelete={handleSelectionDelete}
|
|
1345
|
+
onOrganize={
|
|
1346
|
+
canOrganize
|
|
1347
|
+
? () =>
|
|
1348
|
+
isDesktop
|
|
1349
|
+
? setOrganizeOpen(true)
|
|
1350
|
+
: setMobileOrganizeEntry("select-similar")
|
|
1351
|
+
: undefined
|
|
1352
|
+
}
|
|
1352
1353
|
onJunk={
|
|
1353
1354
|
junkMailboxId && junkMailboxId !== mailboxId && !moveDisabledHint
|
|
1354
1355
|
? handleJunk
|
|
1355
1356
|
: undefined
|
|
1356
1357
|
}
|
|
1357
1358
|
onMarkRead={handleMarkAsRead}
|
|
1358
|
-
onSelectSimilar={
|
|
1359
|
-
accountId ? () => setMobileOrganizeEntry("select-similar") : undefined
|
|
1360
|
-
}
|
|
1361
1359
|
onSomethingElse={
|
|
1362
|
-
|
|
1360
|
+
canOrganize && !isDesktop
|
|
1361
|
+
? () => setMobileOrganizeEntry("something-else")
|
|
1362
|
+
: undefined
|
|
1363
|
+
}
|
|
1364
|
+
moveSlot={selectionMoveSlot}
|
|
1365
|
+
overflowSlot={
|
|
1366
|
+
accountId && mailboxId && selectedCount > 0 && labels.length > 0 ? (
|
|
1367
|
+
<LabelApplyTrigger
|
|
1368
|
+
accountId={accountId}
|
|
1369
|
+
mailboxId={mailboxId}
|
|
1370
|
+
messageIds={Array.from(selectedIds)}
|
|
1371
|
+
/>
|
|
1372
|
+
) : undefined
|
|
1363
1373
|
}
|
|
1364
|
-
moveSlot={mobileMoveSlot}
|
|
1365
1374
|
isBusy={selectionIsBusy}
|
|
1375
|
+
isCounting={escalation.phase.kind === "counting"}
|
|
1366
1376
|
selectAll={selectionSelectAll}
|
|
1367
1377
|
statusLabel={selectionStatusLabel}
|
|
1368
1378
|
progress={selectionProgress}
|
|
1369
|
-
notice={
|
|
1379
|
+
notice={selectionNotice}
|
|
1370
1380
|
/>
|
|
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;
|
|
1381
|
+
);
|
|
1412
1382
|
|
|
1413
1383
|
// Roving tabindex: exactly one row is in the tab order, so Tab moves focus
|
|
1414
1384
|
// into the list at the cursor and Shift+Tab moves back out to the side panel
|
|
@@ -1440,13 +1410,6 @@ export const MessageList = ({
|
|
|
1440
1410
|
aria-multiselectable
|
|
1441
1411
|
aria-label={listTitle}
|
|
1442
1412
|
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
1413
|
>
|
|
1451
1414
|
{/* Virtualizer scaffolding — presentational so the listbox sees the
|
|
1452
1415
|
rows as its options rather than these positioning wrappers. */}
|
|
@@ -1531,7 +1494,9 @@ export const MessageList = ({
|
|
|
1531
1494
|
listMeta={listMeta}
|
|
1532
1495
|
sections={sections}
|
|
1533
1496
|
flatList
|
|
1534
|
-
|
|
1497
|
+
// The results panel is a body, not a list state: it stands in for the
|
|
1498
|
+
// rows and must not fall behind a skeleton while the query re-keys.
|
|
1499
|
+
listState={listHeaderChrome.searchResults ? "ready" : listState}
|
|
1535
1500
|
searchQuery={isSearching ? searchQuery : undefined}
|
|
1536
1501
|
listFilter={listFilter}
|
|
1537
1502
|
listScopeLabel={listScopeLabel}
|
|
@@ -1551,8 +1516,11 @@ export const MessageList = ({
|
|
|
1551
1516
|
isDesktop={isDesktop}
|
|
1552
1517
|
hideHeader={hideHeader}
|
|
1553
1518
|
selectionBar={activeSelectionBar}
|
|
1554
|
-
|
|
1555
|
-
listBody={
|
|
1519
|
+
paneOverlay={mobileOrganizeFlow}
|
|
1520
|
+
listBody={
|
|
1521
|
+
listHeaderChrome.searchResults ??
|
|
1522
|
+
(listState === "ready" ? virtualBody : undefined)
|
|
1523
|
+
}
|
|
1556
1524
|
/>
|
|
1557
1525
|
<ConfirmDialog
|
|
1558
1526
|
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
|
);
|
|
@@ -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,9 @@ 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";
|
|
37
39
|
import type { MessageListCommands } from "./MessageList";
|
|
38
40
|
import type { MessageRowSelection } from "./MessageRow";
|
|
39
|
-
import { SelectionToolbar } from "./SelectionToolbar";
|
|
40
41
|
|
|
41
42
|
interface ThreadRowInteraction {
|
|
42
43
|
focused: boolean;
|
|
@@ -142,7 +143,7 @@ interface ThreadListInteractionProps {
|
|
|
142
143
|
/** Opens a row — the same navigation a click performs. */
|
|
143
144
|
onOpen: (messageId: string, options?: OpenMessageOptions) => void;
|
|
144
145
|
/** Deletes a set of messages. Absent disables the delete key for this list. */
|
|
145
|
-
onDeleteMessages
|
|
146
|
+
onDeleteMessages: (messageIds: string[]) => void;
|
|
146
147
|
isDeleting?: boolean;
|
|
147
148
|
commandsRef?: RefObject<MessageListCommands | null>;
|
|
148
149
|
onTriageContextChange?: (context: TriageContextUpdate) => void;
|
|
@@ -241,14 +242,11 @@ export function ThreadListInteraction({
|
|
|
241
242
|
// the same contract the mailbox list's delete has.
|
|
242
243
|
const [pendingDelete, setPendingDelete] = useState<string[] | null>(null);
|
|
243
244
|
|
|
244
|
-
const requestDeleteIds = useCallback(
|
|
245
|
-
(ids
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
},
|
|
250
|
-
[onDeleteMessages],
|
|
251
|
-
);
|
|
245
|
+
const requestDeleteIds = useCallback((ids: string[]): boolean => {
|
|
246
|
+
if (ids.length === 0) return false;
|
|
247
|
+
setPendingDelete(ids);
|
|
248
|
+
return true;
|
|
249
|
+
}, []);
|
|
252
250
|
|
|
253
251
|
const requestDeleteSelection = useCallback(() => {
|
|
254
252
|
requestDeleteIds(Array.from(selectedIds));
|
|
@@ -272,7 +270,7 @@ export function ThreadListInteraction({
|
|
|
272
270
|
|
|
273
271
|
const confirmDelete = useCallback(() => {
|
|
274
272
|
if (pendingDelete === null) return;
|
|
275
|
-
onDeleteMessages
|
|
273
|
+
onDeleteMessages(pendingDelete);
|
|
276
274
|
setPendingDelete(null);
|
|
277
275
|
exitSelection();
|
|
278
276
|
}, [pendingDelete, onDeleteMessages, exitSelection]);
|
|
@@ -416,25 +414,35 @@ export function ThreadListInteraction({
|
|
|
416
414
|
}
|
|
417
415
|
|
|
418
416
|
interface ThreadListSelectionBarProps {
|
|
417
|
+
/** The view's own name, used until the enclosing header supplies one. */
|
|
418
|
+
title: string;
|
|
419
419
|
onMarkAsRead?: (messageIds: string[]) => void;
|
|
420
420
|
isDeleting?: boolean;
|
|
421
421
|
}
|
|
422
422
|
|
|
423
423
|
/**
|
|
424
|
-
*
|
|
424
|
+
* The starred list's header, which is also its selection bar — the same
|
|
425
|
+
* surface the mailbox list and the brief raise.
|
|
425
426
|
*
|
|
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`.
|
|
427
|
+
* Move is not offered here: starred mail spans accounts and mailboxes, and a
|
|
428
|
+
* move picker needs one account and one source folder to be honest about where
|
|
429
|
+
* the messages go. Delete and mark-read carry no such scope.
|
|
431
430
|
*/
|
|
432
431
|
export function ThreadListSelectionBar({
|
|
432
|
+
title,
|
|
433
433
|
onMarkAsRead,
|
|
434
434
|
isDeleting,
|
|
435
435
|
}: ThreadListSelectionBarProps) {
|
|
436
|
-
const
|
|
437
|
-
|
|
436
|
+
const chrome = useListHeaderChrome();
|
|
437
|
+
const {
|
|
438
|
+
selectedIds,
|
|
439
|
+
selectedCount,
|
|
440
|
+
exitSelection,
|
|
441
|
+
requestDeleteSelection,
|
|
442
|
+
orderedIds,
|
|
443
|
+
allSelected,
|
|
444
|
+
toggleAllLoaded,
|
|
445
|
+
} = useThreadListSelection();
|
|
438
446
|
|
|
439
447
|
const handleMarkAsRead = useCallback(() => {
|
|
440
448
|
onMarkAsRead?.(Array.from(selectedIds));
|
|
@@ -442,12 +450,27 @@ export function ThreadListSelectionBar({
|
|
|
442
450
|
}, [onMarkAsRead, selectedIds, exitSelection]);
|
|
443
451
|
|
|
444
452
|
return (
|
|
445
|
-
<
|
|
446
|
-
|
|
453
|
+
<SelectionTopBar
|
|
454
|
+
title={chrome.title || title}
|
|
455
|
+
navSlot={chrome.navSlot}
|
|
456
|
+
titleMeta={chrome.titleMeta}
|
|
457
|
+
searchSlot={chrome.searchSlot}
|
|
458
|
+
searchField={chrome.searchField}
|
|
459
|
+
idleSlot={chrome.makeFilterSlot}
|
|
460
|
+
count={selectedCount}
|
|
461
|
+
onCancel={exitSelection}
|
|
447
462
|
onDelete={requestDeleteSelection}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
463
|
+
onMarkRead={onMarkAsRead ? handleMarkAsRead : undefined}
|
|
464
|
+
isBusy={isDeleting}
|
|
465
|
+
selectAll={
|
|
466
|
+
orderedIds.length > 0
|
|
467
|
+
? {
|
|
468
|
+
checked: allSelected,
|
|
469
|
+
indeterminate: selectedCount > 0 && !allSelected,
|
|
470
|
+
onChange: toggleAllLoaded,
|
|
471
|
+
}
|
|
472
|
+
: undefined
|
|
473
|
+
}
|
|
451
474
|
/>
|
|
452
475
|
);
|
|
453
476
|
}
|
|
@@ -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);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createContext, type ReactNode, useContext } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The list header's own chrome, as rendered nodes.
|
|
5
|
+
*
|
|
6
|
+
* The list header and the selection bar are one surface (#480), so the nav
|
|
7
|
+
* button, the unread count and the search affordance belong on the same row as
|
|
8
|
+
* the count and the verbs. That row is rendered by the body — the list owns the
|
|
9
|
+
* selection — while the search state it needs belongs to `MailListHeader`.
|
|
10
|
+
* Handing the built nodes down keeps one definition of the chrome and keeps the
|
|
11
|
+
* bar's two states in the same commit: nothing here is derived from a state
|
|
12
|
+
* update that lands after paint.
|
|
13
|
+
*/
|
|
14
|
+
export interface ListHeaderChrome {
|
|
15
|
+
/** The view's name, carried while nothing is ticked. */
|
|
16
|
+
title: string;
|
|
17
|
+
/** Hamburger for the nav slide-over; absent where the nav is a pane. */
|
|
18
|
+
navSlot: ReactNode;
|
|
19
|
+
/** Unread count, beside the title. */
|
|
20
|
+
titleMeta: ReactNode;
|
|
21
|
+
/** Magnifier that opens search, on the tiers whose header owns a field. */
|
|
22
|
+
searchSlot: ReactNode;
|
|
23
|
+
/** The expanded field, which takes the title's place for as long as it is up. */
|
|
24
|
+
searchField: ReactNode;
|
|
25
|
+
/**
|
|
26
|
+
* The read-only two-engine results panel, when a query is being typed and
|
|
27
|
+
* this view answers it with the panel rather than its own rows. The body
|
|
28
|
+
* renders it in place of its list; `MailListHeader` cannot swap the body
|
|
29
|
+
* out from above, because the header is inside it.
|
|
30
|
+
*/
|
|
31
|
+
searchResults: ReactNode;
|
|
32
|
+
/** The search's make-this-a-filter row, up only while nothing is ticked. */
|
|
33
|
+
makeFilterSlot: ReactNode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const NO_CHROME: ListHeaderChrome = {
|
|
37
|
+
title: "",
|
|
38
|
+
navSlot: null,
|
|
39
|
+
titleMeta: null,
|
|
40
|
+
searchSlot: null,
|
|
41
|
+
searchField: null,
|
|
42
|
+
searchResults: null,
|
|
43
|
+
makeFilterSlot: null,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const ListHeaderChromeContext =
|
|
47
|
+
createContext<ListHeaderChrome>(NO_CHROME);
|
|
48
|
+
|
|
49
|
+
export const useListHeaderChrome = (): ListHeaderChrome =>
|
|
50
|
+
useContext(ListHeaderChromeContext);
|