@remit/web-client 0.0.92 → 0.0.94

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.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/src/components/mail/DailyBrief.selection.test.ts +6 -2
  3. package/src/components/mail/DailyBrief.tsx +57 -112
  4. package/src/components/mail/FlaggedList.tsx +42 -0
  5. package/src/components/mail/MailListHeader.tsx +41 -39
  6. package/src/components/mail/MessageList.selection.test.ts +10 -5
  7. package/src/components/mail/MessageList.tsx +78 -82
  8. package/src/components/mail/SelectionWizardHost.tsx +721 -64
  9. package/src/components/mail/ThreadListInteraction.tsx +0 -9
  10. package/src/components/mail/make-filter-entry.test.ts +63 -0
  11. package/src/components/settings/FilterEditor.tsx +1 -1
  12. package/src/hooks/useCreateMailbox.ts +16 -4
  13. package/src/hooks/useFilters.ts +30 -1
  14. package/src/hooks/useMatchSample.ts +63 -0
  15. package/src/hooks/useRulePreview.ts +24 -5
  16. package/src/lib/list-header-chrome.ts +10 -0
  17. package/src/lib/mail-context.ts +0 -9
  18. package/src/lib/organize/organize-model.test.ts +110 -0
  19. package/src/lib/organize/organize-model.ts +69 -0
  20. package/src/lib/organize/rule-model.ts +2 -4
  21. package/src/lib/wizard-history.test.ts +31 -0
  22. package/src/lib/wizard-history.ts +65 -2
  23. package/src/routes/mail.tsx +3 -8
  24. package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +0 -45
  25. package/src/components/mail/organize/MobileOrganizeFlow.tsx +0 -157
  26. package/src/components/mail/organize/OrganizeDialog.render.test.ts +0 -37
  27. package/src/components/mail/organize/OrganizeDialog.tsx +0 -91
  28. package/src/components/mail/organize/OrganizeRuleEditor.render.test.ts +0 -498
  29. package/src/components/mail/organize/OrganizeRuleEditor.tsx +0 -285
  30. package/src/components/mail/organize/SearchFilterDialog.render.test.ts +0 -47
  31. package/src/components/mail/organize/SearchFilterDialog.tsx +0 -90
  32. package/src/components/mail/organize/SearchFilterEditor.render.test.ts +0 -260
  33. package/src/components/mail/organize/SearchFilterEditor.tsx +0 -136
  34. package/src/components/mail/organize/SomethingElsePanel.render.test.ts +0 -54
  35. package/src/components/mail/organize/SomethingElsePanel.tsx +0 -159
  36. package/src/components/mail/organize/rule-editor-states.stories.tsx +0 -147
  37. package/src/components/mail/organize/rule-editor-states.tsx +0 -139
  38. package/src/components/mail/organize/smart-organize.stories.tsx +0 -342
  39. package/src/hooks/useRuleEditorState.ts +0 -182
  40. package/src/hooks/useSearchFilterSeed.render.test.ts +0 -118
  41. package/src/hooks/useSearchFilterSeed.ts +0 -79
  42. package/src/lib/organize/mobile-organize-flow.test.ts +0 -96
  43. package/src/lib/organize/mobile-organize-flow.ts +0 -73
@@ -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 { formatDeleteToTrashTitle, formatNumber } from "@/lib/format";
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,11 +57,13 @@ import {
52
57
  shouldExitSelectionOnNavigate,
53
58
  } from "@/lib/selection-mode";
54
59
  import { cn } from "@/lib/utils";
55
- import { useWizardStepValue } from "@/lib/wizard-history";
60
+ import { useOpenWizard, useWizardStepValue } from "@/lib/wizard-history";
56
61
  import { LabelApplyTrigger } from "./LabelApplyTrigger";
57
62
  import { MoveToTrigger } from "./MoveToTrigger";
58
- import { MobileOrganizeFlow } from "./organize/MobileOrganizeFlow";
59
- import { OrganizeDialog } from "./organize/OrganizeDialog";
63
+ import {
64
+ SelectionWizardHost,
65
+ type WizardSelectionMessage,
66
+ } from "./SelectionWizardHost";
60
67
  import { SwipeableMessageRow } from "./SwipeableMessageRow";
61
68
 
62
69
  /**
@@ -234,13 +241,11 @@ export const MessageList = ({
234
241
  const navigate = useNavigate();
235
242
  const isDesktop = useIsDesktop();
236
243
  const wizardStep = useWizardStepValue();
237
- const [organizeOpen, setOrganizeOpen] = useState(false);
238
- // Mobile only: which selection-sheet entry opened the guided organize flow
239
- // (`null` when closed). Desktop uses `organizeOpen` + `OrganizeDialog`
240
- // instead a sheet is a touch pattern (issue #211).
241
- const [mobileOrganizeEntry, setMobileOrganizeEntry] = useState<
242
- "select-similar" | "something-else" | null
243
- >(null);
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");
244
249
  const isSearching = !!searchQuery?.trim();
245
250
  const listHeaderChrome = useListHeaderChrome();
246
251
  const { labels } = useLabelList(accountId);
@@ -317,13 +322,6 @@ export const MessageList = ({
317
322
  // it back to the count, and across that render the two disagree.
318
323
  const isMultiSelectMode = deriveIsMultiSelectMode(selectedCount, isDesktop);
319
324
 
320
- // When the selection empties — cancel, a completed action, a mailbox switch —
321
- // the guided organize entry must reset too, so a later re-selection doesn't
322
- // reopen the flow on a stale entry (issue #211).
323
- useEffect(() => {
324
- if (selectedCount === 0) setMobileOrganizeEntry(null);
325
- }, [selectedCount]);
326
-
327
325
  // Pending delete, awaiting confirmation. `null` means the dialog is closed.
328
326
  // `source: "ids"` snapshots the concrete ids at request time so a selection
329
327
  // change behind the dialog can't retarget the delete — every keyboard/desktop
@@ -511,17 +509,23 @@ export const MessageList = ({
511
509
  // false for a plain click (caller lets the Link navigate).
512
510
  const orderedIds = useMemo(() => threads.map((t) => t.messageId), [threads]);
513
511
 
514
- // Sender addresses of the checked rows, for the organize widen's literal
515
- // fallback when this deployment ships no vector pipeline (the sheet matches
516
- // all mail from these senders). Read off the already-loaded thread rows, so
517
- // no extra round-trip; the widen hook dedupes.
518
- const selectedSenders = useMemo(() => {
519
- const emails: string[] = [];
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[] = [];
520
518
  for (const thread of threads) {
521
519
  if (!selectedIds.has(thread.messageId)) continue;
522
- if (thread.fromEmail) emails.push(thread.fromEmail);
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
+ });
523
527
  }
524
- return emails;
528
+ return rows;
525
529
  }, [threads, selectedIds]);
526
530
  const handleRowSelect = useCallback(
527
531
  (messageId: string, modifiers: SelectionModifiers): boolean => {
@@ -877,6 +881,23 @@ export const MessageList = ({
877
881
  return undefined;
878
882
  }, [selectedCount, selectedIds, threads]);
879
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
+
880
901
  // Swipe-to-delete single message
881
902
  const handleSwipeDelete = useCallback(
882
903
  (messageId: string) => {
@@ -1285,15 +1306,10 @@ export const MessageList = ({
1285
1306
  }
1286
1307
  : undefined;
1287
1308
 
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 =
1291
- escalationNotice ??
1292
- (moveDisabledHint
1293
- ? { tone: "warning" as const, text: moveDisabledHint }
1294
- : undefined);
1295
-
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).
1296
1311
  const selectionMoveSlot =
1312
+ !canOpenWizard &&
1297
1313
  (onMoveMessages || escalation.phase.kind === "escalated") &&
1298
1314
  accountId &&
1299
1315
  mailboxId ? (
@@ -1306,28 +1322,13 @@ export const MessageList = ({
1306
1322
  />
1307
1323
  ) : undefined;
1308
1324
 
1309
- // The guided organize flow (issue #211) covers the pane, which is its
1310
- // positioned ancestor.
1311
- const mobileOrganizeFlow =
1312
- mobileOrganizeEntry && accountId && !isDesktop && selectedCount > 0 ? (
1313
- <MobileOrganizeFlow
1314
- entry={mobileOrganizeEntry}
1315
- accountId={accountId}
1316
- selectedMessageIds={Array.from(selectedIds)}
1317
- selectedSenders={selectedSenders}
1318
- junkMailboxId={junkMailboxId}
1319
- onClose={() => {
1320
- setMobileOrganizeEntry(null);
1321
- exitSelection();
1322
- }}
1323
- />
1324
- ) : undefined;
1325
-
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;
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);
1331
1332
 
1332
1333
  // One selection surface at every width, always mounted: it is the list
1333
1334
  // header. With nothing ticked it names the mailbox and carries the header's
@@ -1345,27 +1346,24 @@ export const MessageList = ({
1345
1346
  idleSlot={listHeaderChrome.makeFilterSlot}
1346
1347
  count={selectionCount}
1347
1348
  onCancel={handleSelectionCancel}
1348
- onDelete={handleSelectionDelete}
1349
- onOrganize={
1350
- canOrganize
1351
- ? () =>
1352
- isDesktop
1353
- ? setOrganizeOpen(true)
1354
- : setMobileOrganizeEntry("select-similar")
1355
- : undefined
1349
+ onDelete={
1350
+ canOpenWizard ? () => startWizard("delete") : handleSelectionDelete
1356
1351
  }
1352
+ onMove={canOpenWizard ? () => startWizard("move") : undefined}
1353
+ moveSlot={selectionMoveSlot}
1354
+ onOrganize={canOpenWizard ? () => startWizard("organize") : undefined}
1357
1355
  onJunk={
1358
- junkMailboxId && junkMailboxId !== mailboxId && !moveDisabledHint
1359
- ? handleJunk
1356
+ junkMailboxId && junkMailboxId !== mailboxId
1357
+ ? canOpenWizard
1358
+ ? () => startWizard("junk")
1359
+ : !moveDisabledHint
1360
+ ? handleJunk
1361
+ : undefined
1360
1362
  : undefined
1361
1363
  }
1362
- onMarkRead={handleMarkAsRead}
1363
- onSomethingElse={
1364
- canOrganize && !isDesktop
1365
- ? () => setMobileOrganizeEntry("something-else")
1366
- : undefined
1364
+ onMarkRead={
1365
+ canOpenWizard ? () => startWizard("markRead") : handleMarkAsRead
1367
1366
  }
1368
- moveSlot={selectionMoveSlot}
1369
1367
  overflowSlot={
1370
1368
  accountId && mailboxId && selectedCount > 0 && labels.length > 0 ? (
1371
1369
  <LabelApplyTrigger
@@ -1520,7 +1518,6 @@ export const MessageList = ({
1520
1518
  isDesktop={isDesktop}
1521
1519
  hideHeader={hideHeader}
1522
1520
  selectionBar={activeSelectionBar}
1523
- paneOverlay={mobileOrganizeFlow}
1524
1521
  listBody={
1525
1522
  listHeaderChrome.searchResults ??
1526
1523
  (listState === "ready" ? virtualBody : undefined)
@@ -1543,15 +1540,14 @@ export const MessageList = ({
1543
1540
  onConfirm={handleConfirmDelete}
1544
1541
  onCancel={handleCancelDelete}
1545
1542
  />
1546
- {organizeOpen && accountId && (
1547
- <OrganizeDialog
1548
- open={organizeOpen}
1549
- accountId={accountId}
1550
- selectedMessageIds={Array.from(selectedIds)}
1551
- selectedSenders={selectedSenders}
1552
- onClose={() => setOrganizeOpen(false)}
1553
- />
1554
- )}
1543
+ <SelectionWizardHost
1544
+ verb={wizardVerb}
1545
+ accountId={accountId}
1546
+ mailboxId={mailboxId}
1547
+ selection={wizardSelection}
1548
+ crossAccount={moveDisabledHint !== undefined}
1549
+ onFinished={exitSelection}
1550
+ />
1555
1551
  </>
1556
1552
  );
1557
1553
  };