@remit/web-client 0.0.89 → 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.
@@ -5,8 +5,7 @@ import {
5
5
  type MessageListFilter,
6
6
  MessageListLoadingMore,
7
7
  MessageListPane,
8
- SELECTION_SHEET_TEASER_HEIGHT,
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 mobile sheet has no inline hint slot, so the cross-account move
1286
- // restriction rides in as the lowest-priority notice.
1287
- const mobileNotice =
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
- // Mobile: which content the peeking sheet routes to, from the escalation
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) overlays the same positioned pane
1329
- // ancestor as the sheet, so it is rendered from the sheet slot. The sheet
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
- const mobileSelectionSheet = showMobileSheet ? (
1347
- <SelectionSheet
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
- accountId ? () => setMobileOrganizeEntry("something-else") : undefined
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={mobileNotice}
1379
+ notice={selectionNotice}
1370
1380
  />
1371
- ) : undefined;
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
- listState={listState}
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
- selectionSheet={mobileSelectionSurface}
1555
- listBody={listState === "ready" ? virtualBody : undefined}
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: options.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
- { selectedMessageId: undefined, onOpen: () => undefined, commandsRef },
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?: (messageIds: string[]) => void;
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: string[]): boolean => {
246
- if (!onDeleteMessages || ids.length === 0) return false;
247
- setPendingDelete(ids);
248
- return true;
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?.(pendingDelete);
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
- * Reduced selection bar for Flagged, mounted inside `ThreadListInteraction`.
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: Flagged spans accounts and mailboxes, and a move
427
- * picker needs one account and one source folder to be honest about where the
428
- * messages go. Delete and mark-read carry no such scope. The brief resolves
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 { selectedIds, selectedCount, exitSelection, requestDeleteSelection } =
437
- useThreadListSelection();
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
- <SelectionToolbar
446
- selectedCount={selectedCount}
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
- onClearSelection={exitSelection}
449
- onMarkAsRead={onMarkAsRead ? handleMarkAsRead : undefined}
450
- isDeleting={isDeleting}
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
- await harness.flush();
81
- await harness.flush();
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);
Binary file
@@ -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);