@remit/web-client 0.0.321 → 0.0.323

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 (190) hide show
  1. package/package.json +4 -4
  2. package/src/auth/SignInAgain.tsx +18 -0
  3. package/src/auth/amplify-config.test.ts +1 -107
  4. package/src/auth/auth-interceptor.test.ts +0 -6
  5. package/src/auth/cognito/cognito-token.test.ts +2 -4
  6. package/src/components/calendar/AgendaStrip.tsx +1 -1
  7. package/src/components/calendar/CalendarWorkspace.tsx +1 -1
  8. package/src/components/calendar/ChangeCalendarSuggestion.tsx +1 -1
  9. package/src/components/calendar/OpenCalendarEvent.tsx +1 -18
  10. package/src/components/compose/ComposeForm.tsx +11 -6
  11. package/src/components/compose/FullCompose.tsx +1 -1
  12. package/src/components/compose/MobileComposeSheet.tsx +1 -2
  13. package/src/components/compose/compose.stories.tsx +18 -0
  14. package/src/components/compose/draft-discard-failure.render.test.ts +138 -0
  15. package/src/components/config-import/config-import.fixtures.ts +8 -9
  16. package/src/components/config-import/config-import.stories.tsx +2 -2
  17. package/src/components/layout/MailTopBar.tsx +3 -0
  18. package/src/components/mail/BriefPane.tsx +0 -3
  19. package/src/components/mail/ConversationView.tsx +1 -3
  20. package/src/components/mail/DailyBrief.tsx +5 -28
  21. package/src/components/mail/FlaggedList.tsx +3 -4
  22. package/src/components/mail/FlaggedPane.tsx +0 -3
  23. package/src/components/mail/IntelligenceDrawer.tsx +0 -3
  24. package/src/components/mail/IntelligencePane.render.test.ts +0 -1
  25. package/src/components/mail/IntelligencePane.test.ts +1 -95
  26. package/src/components/mail/IntelligencePane.tsx +3 -93
  27. package/src/components/mail/MailListHeader.tsx +12 -43
  28. package/src/components/mail/MailNav.tsx +6 -2
  29. package/src/components/mail/MailViewChrome.tsx +1 -11
  30. package/src/components/mail/MailboxPane.tsx +4 -34
  31. package/src/components/mail/MessageActionMenu.tsx +6 -6
  32. package/src/components/mail/MessageAttachments.tsx +35 -0
  33. package/src/components/mail/MessageBody.tsx +1 -0
  34. package/src/components/mail/MessageBodyErrorBanner.test.ts +0 -30
  35. package/src/components/mail/MessageCard.tsx +16 -4
  36. package/src/components/mail/MessageList.run-reporting.test.ts +2 -46
  37. package/src/components/mail/MessageList.tsx +23 -11
  38. package/src/components/mail/MessageRow.render.test.ts +0 -7
  39. package/src/components/mail/MoveToTrigger.tsx +1 -1
  40. package/src/components/mail/RawMessageView.tsx +1 -1
  41. package/src/components/mail/SelectionWizardHost.search-entry.test.ts +0 -1
  42. package/src/components/mail/SelectionWizardHost.tsx +16 -102
  43. package/src/components/mail/authenticity-warning.stories.tsx +0 -21
  44. package/src/components/mail/daily-brief.stories.tsx +31 -17
  45. package/src/components/mail/enter-cursor-late-open.render.test.ts +197 -0
  46. package/src/components/mail/intelligence-drawer.stories.tsx +0 -2
  47. package/src/components/mail/message-gone.stories.tsx +44 -0
  48. package/src/components/mail/offer-times-reply.render.test.ts +25 -1
  49. package/src/components/mail/reclassify-remove-override.render.test.ts +2 -6
  50. package/src/components/overlay-escape.render.test.ts +7 -3
  51. package/src/components/settings/AccountFormPanel.render.test.ts +26 -3
  52. package/src/components/settings/AccountFormPanel.test.ts +1 -36
  53. package/src/components/settings/AccountFormPanel.tsx +5 -25
  54. package/src/components/settings/AccountServices.tsx +1 -1
  55. package/src/components/settings/AdvancedNavIcon.tsx +3 -3
  56. package/src/components/settings/CalendarFeedCard.tsx +2 -1
  57. package/src/components/settings/CalendarSubscribeCard.tsx +1 -1
  58. package/src/components/settings/CalendarSubscriptionStatus.tsx +1 -2
  59. package/src/components/settings/FilterActionError.tsx +1 -1
  60. package/src/components/settings/FilterEditor.render.test.ts +3 -27
  61. package/src/components/settings/FilterEditor.tsx +4 -24
  62. package/src/components/settings/FilterEditorSurface.tsx +0 -3
  63. package/src/components/settings/FiltersList.render.test.ts +5 -24
  64. package/src/components/settings/FiltersList.stories.tsx +0 -1
  65. package/src/components/settings/FiltersList.tsx +10 -35
  66. package/src/components/settings/WarningsNavIcon.tsx +12 -0
  67. package/src/components/settings/{QuarantinePanel.delimiter.test.ts → WarningsPanel.dom.test.ts} +64 -5
  68. package/src/components/settings/WarningsPanel.render.test.ts +56 -0
  69. package/src/components/settings/WarningsPanel.tsx +244 -0
  70. package/src/components/settings/accounts.stories.tsx +18 -0
  71. package/src/components/settings/advanced.stories.tsx +3 -125
  72. package/src/components/settings/settings-filter.stories.tsx +2 -42
  73. package/src/components/settings/warnings.stories.tsx +290 -0
  74. package/src/components/ui/ErrorBannerProvider.tsx +5 -3
  75. package/src/components/ui/error-banners.test.ts +1 -22
  76. package/src/components/ui/error-banners.ts +9 -20
  77. package/src/hooks/calendar/useCalendarCollectionWrites.ts +2 -1
  78. package/src/hooks/calendar/useCalendarSuggestions.ts +2 -1
  79. package/src/hooks/calendar/useCalendarWrites.ts +2 -1
  80. package/src/hooks/intelligence-calendar.render.test.ts +0 -1
  81. package/src/hooks/useDeleteMessages.test.ts +1 -42
  82. package/src/hooks/useFilters.test.ts +0 -8
  83. package/src/hooks/useFollowFocusOpen.render.test.ts +1 -11
  84. package/src/hooks/useIntelligenceData.test.ts +9 -47
  85. package/src/hooks/useIntelligenceData.ts +7 -100
  86. package/src/hooks/useLayoutTier.test.ts +0 -48
  87. package/src/hooks/useReportSpam.ts +1 -1
  88. package/src/hooks/useRescueCandidates.test.ts +0 -23
  89. package/src/hooks/useRulePreview.ts +8 -7
  90. package/src/hooks/useSearchTokenContext.ts +2 -3
  91. package/src/hooks/useStaleAccountSync.test.ts +0 -17
  92. package/src/hooks/useStarredThreads.test.ts +0 -10
  93. package/src/hooks/useTriggerSync.test.ts +0 -6
  94. package/src/hooks/useVisualViewport.render.test.ts +63 -0
  95. package/src/hooks/useWarningsAttention.ts +37 -0
  96. package/src/lib/account-order.test.ts +0 -10
  97. package/src/lib/adjacent-message.test.ts +0 -9
  98. package/src/lib/app-info.ts +0 -3
  99. package/src/lib/autodiscovery.test.ts +0 -47
  100. package/src/lib/bug-report.test.ts +38 -0
  101. package/src/lib/bug-report.ts +10 -7
  102. package/src/lib/bulk-action-copy.ts +1 -1
  103. package/src/lib/calendar-route.test.ts +0 -5
  104. package/src/lib/config-import.test.ts +3 -39
  105. package/src/lib/dedupe-thread-messages.test.ts +0 -4
  106. package/src/lib/desktop-breakpoint.test.ts +0 -8
  107. package/src/lib/drafts.test.ts +0 -23
  108. package/src/lib/fatal-error.test.ts +0 -19
  109. package/src/lib/folder-roles.test.ts +0 -4
  110. package/src/lib/hot-sync-window.test.ts +0 -5
  111. package/src/lib/list-header-chrome.ts +1 -1
  112. package/src/lib/mail-context.ts +1 -1
  113. package/src/lib/message-attachments.test.ts +77 -0
  114. package/src/lib/message-attachments.ts +31 -15
  115. package/src/lib/message-body-source.test.ts +21 -4
  116. package/src/lib/message-body-source.ts +5 -2
  117. package/src/lib/onboarding-completion.test.ts +0 -16
  118. package/src/lib/organize/filter-edit-model.test.ts +2 -32
  119. package/src/lib/organize/filter-edit-model.ts +6 -16
  120. package/src/lib/organize/organize-model.test.ts +7 -40
  121. package/src/lib/organize/organize-model.ts +16 -18
  122. package/src/lib/organize/rule-model.test.ts +24 -202
  123. package/src/lib/organize/rule-model.ts +19 -166
  124. package/src/lib/organize/search-to-rule.test.ts +2 -36
  125. package/src/lib/organize/search-to-rule.ts +1 -16
  126. package/src/lib/provider-presets.test.ts +1 -77
  127. package/src/lib/query-error-handler.test.ts +0 -61
  128. package/src/lib/query-escalation.integration.test.ts +4 -4
  129. package/src/lib/result-count.ts +1 -1
  130. package/src/lib/result-folder.ts +1 -1
  131. package/src/lib/rum-adapter.test.ts +0 -104
  132. package/src/lib/search-query.test.ts +0 -11
  133. package/src/lib/search-result.test.ts +2 -142
  134. package/src/lib/search-result.ts +3 -73
  135. package/src/lib/search-scope.test.ts +1 -92
  136. package/src/lib/search-scope.ts +0 -29
  137. package/src/lib/search-surface.ts +2 -2
  138. package/src/lib/search-token-index.test.ts +0 -8
  139. package/src/lib/search-tokens.ts +15 -16
  140. package/src/lib/starred-rows.test.ts +0 -4
  141. package/src/lib/sync-warning-entries.ts +33 -0
  142. package/src/lib/wizard-selection.ts +1 -1
  143. package/src/mocks/story-frame/mail-handlers.ts +12 -8
  144. package/src/mocks/story-frame/mail-world.ts +5 -0
  145. package/src/routeTree.gen.ts +21 -0
  146. package/src/routes/calendar/$view.$date/index.tsx +1 -1
  147. package/src/routes/calendar.tsx +2 -1
  148. package/src/routes/mail/brief.tsx +1 -1
  149. package/src/routes/mail/flagged.tsx +1 -1
  150. package/src/routes/mail.tsx +5 -3
  151. package/src/routes/settings/-accounts.test.ts +0 -5
  152. package/src/routes/settings/-filters.test.ts +0 -1
  153. package/src/routes/settings/-shared/help-copy.tsx +15 -0
  154. package/src/routes/settings/accounts.tsx +1 -1
  155. package/src/routes/settings/advanced.tsx +1 -4
  156. package/src/routes/settings/filters.tsx +1 -2
  157. package/src/routes/settings/folders.tsx +1 -1
  158. package/src/routes/settings/labels.tsx +1 -1
  159. package/src/routes/settings/senders.tsx +1 -1
  160. package/src/routes/settings/warnings.tsx +36 -0
  161. package/src/routes/settings.tsx +7 -0
  162. package/src/routing/wizard-history.test.ts +1 -45
  163. package/src/runtime-config.ts +5 -0
  164. package/src/test-support/fixtures.ts +1 -1
  165. package/src/components/compose/draft-discard-banner.test.ts +0 -76
  166. package/src/components/mail/ReadingPaneEmpty.render.test.ts +0 -42
  167. package/src/components/mail/SpamRescue.render.test.ts +0 -35
  168. package/src/components/mail/intelligence-semantic-off.render.test.ts +0 -111
  169. package/src/components/mail/selection-surface-rollcall.test.ts +0 -270
  170. package/src/components/settings/QuarantinePanel.render.test.ts +0 -71
  171. package/src/components/settings/QuarantinePanel.tsx +0 -178
  172. package/src/components/ui/DropdownMenu.tsx +0 -99
  173. package/src/components/ui/EmptyState.tsx +0 -11
  174. package/src/components/ui/ErrorBanner.render.test.ts +0 -104
  175. package/src/components/ui/ErrorBanner.stories.tsx +0 -132
  176. package/src/components/ui/ErrorBanner.tsx +0 -106
  177. package/src/components/ui/ErrorBannerStack.tsx +0 -35
  178. package/src/components/ui/ErrorState.render.test.ts +0 -82
  179. package/src/components/ui/ErrorState.tsx +0 -80
  180. package/src/components/ui/KeyboardShortcutsModal.tsx +0 -113
  181. package/src/hooks/useMoveMessages.test.ts +0 -63
  182. package/src/hooks/useOrganizeWiden.test.ts +0 -217
  183. package/src/hooks/useOrganizeWiden.ts +0 -112
  184. package/src/hooks/useSemanticSearch.ts +0 -132
  185. package/src/hooks/useSemanticSearchEnabled.ts +0 -23
  186. package/src/hooks/useVisualViewport.test.ts +0 -126
  187. package/src/lib/organize/sender-fallback.test.ts +0 -52
  188. package/src/lib/organize/sender-fallback.ts +0 -38
  189. package/src/lib/rescue-telemetry.test.ts +0 -75
  190. package/src/router.test.ts +0 -81
@@ -46,10 +46,8 @@ import { useCreateFilter } from "@/hooks/useFilters";
46
46
  import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
47
47
  import { useMatchSample, useSearchMatchSample } from "@/hooks/useMatchSample";
48
48
  import { useOrganizeJob } from "@/hooks/useOrganizeJob";
49
- import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
50
49
  import { useRulePreview } from "@/hooks/useRulePreview";
51
50
  import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
52
- import { useSemanticSearchEnabled } from "@/hooks/useSemanticSearchEnabled";
53
51
  import type {
54
52
  BulkActionProgress,
55
53
  BulkActionTarget,
@@ -62,6 +60,7 @@ import { useMailContext } from "@/lib/mail-context";
62
60
  import { buildMoveOptions, folderDelimiter } from "@/lib/move-options";
63
61
  import {
64
62
  buildWizardDraft,
63
+ type OrganizeMatchPredicate,
65
64
  type OrganizeScope,
66
65
  organizeScopeFor,
67
66
  } from "@/lib/organize/organize-model";
@@ -70,7 +69,6 @@ import {
70
69
  SUPPORTED_CLAUSE_FIELDS,
71
70
  } from "@/lib/organize/rule-model";
72
71
  import { searchRuleAccountId } from "@/lib/organize/search-to-rule";
73
- import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
74
72
  import { useSearchConversion } from "@/lib/search-conversion";
75
73
  import type { WizardSelectionMessage } from "@/lib/wizard-selection";
76
74
  import { useWizardEntryValue, useWizardStep } from "@/routing";
@@ -186,7 +184,6 @@ const NO_CONVERSION: SearchConversion = {
186
184
  matchOperator: "all",
187
185
  droppedFacets: [],
188
186
  keptTerms: false,
189
- droppedSemantic: false,
190
187
  };
191
188
 
192
189
  /** A search entry has nothing ticked; its match is the query's clauses (#477 3.2). */
@@ -269,7 +266,7 @@ const bulkActionFor = (
269
266
  };
270
267
 
271
268
  /**
272
- * Whether a widened match is applied by the server's own pass rather than over
269
+ * Whether the properties match is applied by the server's own pass rather than over
273
270
  * the ids it matched. The back-apply job carries a move and nothing else, so
274
271
  * Move, Junk and Organize hand it the whole match; Delete and Mark read have no
275
272
  * server-side pass and act on the ids the preview matched.
@@ -290,8 +287,7 @@ const noDestinationReason = (verb: Verb): string =>
290
287
 
291
288
  /**
292
289
  * Where the wizard meets the app (#483). The steps and their bodies belong to
293
- * `@remit/ui`; everything that talks to a server is here: the widen, the live
294
- * count, the folder create, the bulk call, the back-apply job and the filter.
290
+ * `@remit/ui`; everything that talks to a server is here: the live count, the folder create, the bulk call, the back-apply job and the filter.
295
291
  *
296
292
  * Every commit routes through `organize-model.ts`, so the four `OrganizeScope`
297
293
  * values are reconstructed in one place from the two answers the wizard asks
@@ -351,7 +347,6 @@ function SelectionWizardSession({
351
347
  undefined,
352
348
  );
353
349
  const [nudgedStep, setNudgedStep] = useState<StepId | undefined>(undefined);
354
- const [semanticFallbackTaken, setSemanticFallbackTaken] = useState(false);
355
350
  const [committedScope, setCommittedScope] = useState<
356
351
  OrganizeScope | undefined
357
352
  >(undefined);
@@ -386,7 +381,6 @@ function SelectionWizardSession({
386
381
  () => selection.map((message) => message.email).filter(Boolean),
387
382
  [selection],
388
383
  );
389
- const anchorMessageId = messageIds[0];
390
384
  const subjects = useSelectedSubjects(messageIds);
391
385
  const { junkMailboxId } = useJunkMailbox(accountId);
392
386
  const { inboxMailboxId } = useInboxMailbox(accountId);
@@ -410,26 +404,11 @@ function SelectionWizardSession({
410
404
  [selection, inboxMailboxId],
411
405
  );
412
406
 
413
- // No door is offered over an escalated predicate, so nothing here has a widen
414
- // to probe for.
415
- const widen = useOrganizeWiden(
416
- escalated ? undefined : accountId,
417
- anchorMessageId,
418
- senders,
419
- subjects,
420
- );
421
- const { preview: probeWiden } = widen;
422
- // The similar door has to know before it is pressed whether it can run, so
423
- // the probe fires with the wizard rather than with the door (#477 3.6).
424
- useEffect(() => {
425
- probeWiden();
426
- }, [probeWiden]);
427
-
428
407
  // A door the wizard resolves itself, through the preview endpoint. The
429
408
  // escalated predicate is resolved by the list before the wizard opens, and
430
409
  // the ticked list is its own answer, so neither is previewed.
431
- const previewed = mode === "similar" || mode === "properties";
432
- const literalPredicate: OrganizeMatchPredicate = useMemo(
410
+ const previewed = mode === "properties";
411
+ const predicate: OrganizeMatchPredicate = useMemo(
433
412
  () => ({
434
413
  matchOperator: draft.matchOperator === "all" ? "And" : "Or",
435
414
  literalClauses: draft.clauses.map((clause) => ({
@@ -439,8 +418,6 @@ function SelectionWizardSession({
439
418
  }),
440
419
  [draft.clauses, draft.matchOperator],
441
420
  );
442
- const predicate =
443
- mode === "similar" ? widen.matchPredicate : literalPredicate;
444
421
  // The count and the ids both come from the server, never from paging the list
445
422
  // in the browser (#477 5.3). The ticked list is its own count, so no request
446
423
  // is made for it.
@@ -552,11 +529,11 @@ function SelectionWizardSession({
552
529
  // is how a footer comes to name a screen nobody is looking at.
553
530
  const current = steps[stepIndex(steps, step)];
554
531
 
555
- // A filter, a folder, and the preview that counts a widened door all belong to
556
- // one account, so a selection spanning accounts reaches none of them and is
557
- // told so on the step that asks (#477 5.5, #523). A selection spanning folders
558
- // of one account is told about folders instead, and keeps its account: the
559
- // preview behind a widened door has something to count against. The one-off
532
+ // A filter, a folder, and the preview that counts the properties door all
533
+ // belong to one account, so a selection spanning accounts reaches none of them
534
+ // and is told so on the step that asks (#477 5.5, #523). A selection spanning
535
+ // folders of one account is told about folders instead, and keeps its account:
536
+ // the preview behind the properties door has something to count against. The one-off
560
537
  // scope and the ticked rows act on the messages themselves and are unaffected.
561
538
  const wizardScope = wizardScopeFor(accountId, selectionRestriction);
562
539
  // An escalated match is restricted on the same step for its own reason: the
@@ -581,65 +558,15 @@ function SelectionWizardSession({
581
558
  const changeMode = useCallback(
582
559
  (next: MatchDoor) => {
583
560
  setDoor(next);
584
- setDraft((held) => ({
585
- ...held,
586
- widen:
587
- next === "similar" && anchorMessageId
588
- ? { anchorCount: Math.max(selection.length, 1) }
589
- : undefined,
590
- ...(next === "properties" && held.clauses.length === 0
591
- ? { clauses: seedPropertyClauses() }
592
- : {}),
593
- }));
561
+ setDraft((held) =>
562
+ next === "properties" && held.clauses.length === 0
563
+ ? { ...held, clauses: seedPropertyClauses() }
564
+ : held,
565
+ );
594
566
  },
595
- [anchorMessageId, selection.length, seedPropertyClauses],
567
+ [seedPropertyClauses],
596
568
  );
597
569
 
598
- // The dimmed similar door, pressed. What the widen would have fallen back to
599
- // is filled in on the property step instead of standing in for the semantic
600
- // match without saying so (#477 3.6) — the same agreement the property step
601
- // itself seeds from (#458), not senders alone: a selection with nothing in
602
- // common on sender still shares a subject worth matching on.
603
- const takeSemanticFallback = useCallback(() => {
604
- setSemanticFallbackTaken(true);
605
- setDoor("properties");
606
- setDraft((held) => ({
607
- ...held,
608
- widen: undefined,
609
- clauses: seedPropertyClauses(),
610
- matchOperator: "any",
611
- }));
612
- goToStep("properties");
613
- }, [seedPropertyClauses, goToStep]);
614
-
615
- // The probe lands after the door is on screen, so it can report the widen
616
- // unavailable while the user is already holding it. The fallback is taken then
617
- // rather than left as a door that counts nothing: the property step says, in
618
- // so many words, that these are the senders it substituted (#477 3.6).
619
- // Off is a deployment setting, not a probe result, and it reaches the door the
620
- // same way the runtime failure does: dimmed, pressable, and falling back to
621
- // the senders. Only the copy differs (#1068).
622
- const semanticOff = useSemanticSearchEnabled() === false;
623
- const semanticUnavailable =
624
- semanticOff || widen.semanticUnavailable || widen.isError;
625
- // Only while the door is the screen: the probe re-fires when the anchor
626
- // changes under a background refetch, and a late answer that moved the step
627
- // from Review would push an entry the wizard does not own and leave the count
628
- // it rewinds by wrong.
629
- useEffect(() => {
630
- if (current !== "match") return;
631
- if (mode !== "similar" || !semanticUnavailable || semanticFallbackTaken) {
632
- return;
633
- }
634
- takeSemanticFallback();
635
- }, [
636
- current,
637
- mode,
638
- semanticUnavailable,
639
- semanticFallbackTaken,
640
- takeSemanticFallback,
641
- ]);
642
-
643
570
  const startAddClause = useCallback(() => {
644
571
  setClauseEdit({ mode: "add", draft: { field: "From", value: "" } });
645
572
  }, []);
@@ -757,7 +684,6 @@ function SelectionWizardSession({
757
684
  const organizeDraft = buildWizardDraft({
758
685
  mode: door,
759
686
  ruleScope: named.scope,
760
- anchorMessageId,
761
687
  clauses: named.clauses,
762
688
  matchOperator: named.matchOperator,
763
689
  moveMailboxId: named.moveMailboxId,
@@ -797,7 +723,6 @@ function SelectionWizardSession({
797
723
  runEscalated,
798
724
  door,
799
725
  named,
800
- anchorMessageId,
801
726
  verb,
802
727
  accountId,
803
728
  bulkTargets,
@@ -1016,10 +941,6 @@ function SelectionWizardSession({
1016
941
  messages: sampleMessages,
1017
942
  count,
1018
943
  label: mode === "selected" ? "Your selection" : "A sample of what matches",
1019
- emptyReason:
1020
- mode === "similar" && semanticUnavailable
1021
- ? ("notIndexed" as const)
1022
- : ("noMatch" as const),
1023
944
  loading: escalated
1024
945
  ? escalatedSample.isPending
1025
946
  : previewed && (count.status === "loading" || matchSample.isPending),
@@ -1044,12 +965,6 @@ function SelectionWizardSession({
1044
965
  mode,
1045
966
  accountId: wizardScope.accountId,
1046
967
  onModeChange: changeMode,
1047
- semanticUnavailable,
1048
- semanticOff,
1049
- semanticErrorDetail:
1050
- widen.error instanceof Error ? widen.error.message : undefined,
1051
- semanticFallbackTaken,
1052
- onSemanticFallback: takeSemanticFallback,
1053
968
  escalatedScope: escalated?.scope,
1054
969
  sample,
1055
970
  }}
@@ -1070,7 +985,6 @@ function SelectionWizardSession({
1070
985
  conversionNotice: conversion
1071
986
  ? searchConversionNotice(conversion)
1072
987
  : undefined,
1073
- semanticFallbackTaken,
1074
988
  sample: { ...sample, label: "What this matches" },
1075
989
  }}
1076
990
  folder={{
@@ -48,30 +48,9 @@ const intelligence: IntelligenceData = {
48
48
  claimedBrand: "Mondial Relay",
49
49
  summary:
50
50
  "The display name claims “Mondial Relay”, but the message was sent and signed by a personal gmail.example mailbox — not mondialrelay.fr. Real carriers send from their own domain.",
51
- similarCount: 15,
52
51
  },
53
52
  category: { value: "automated" },
54
53
  flags: {},
55
- similar: [
56
- {
57
- id: "sim_dhl",
58
- mailboxId: "mbx_personal_junk",
59
- threadId: "thr_sim_dhl",
60
- fromName: "DHL Express",
61
- subject: "Action required: customs fee outstanding",
62
- timeLabel: "Thu",
63
- matched: "body",
64
- },
65
- {
66
- id: "sim_postnl",
67
- mailboxId: "mbx_personal_junk",
68
- threadId: "thr_sim_postnl",
69
- fromName: "PostNL",
70
- subject: "Uw pakket kon niet worden bezorgd",
71
- timeLabel: "28 May",
72
- matched: "body",
73
- },
74
- ],
75
54
  };
76
55
 
77
56
  /**
@@ -7,6 +7,8 @@ import { longPress } from "@/mocks/story-frame/touch";
7
7
 
8
8
  const world = mailWorld();
9
9
 
10
+ const routeLoad = { timeout: 5000 };
11
+
10
12
  const meta = {
11
13
  title: "Playground/Shipped/Mail/Daily brief",
12
14
  component: AppStory,
@@ -50,7 +52,9 @@ const pick = async (
50
52
  export const Default: Story = {
51
53
  play: async ({ canvasElement }) => {
52
54
  const canvas = within(canvasElement);
53
- await expect(await canvas.findByText("Q3 planning")).toBeVisible();
55
+ await expect(
56
+ await canvas.findByText("Q3 planning", {}, routeLoad),
57
+ ).toBeVisible();
54
58
  await expect(canvas.getByText("Autumn sale starts now")).toBeVisible();
55
59
  },
56
60
  };
@@ -59,7 +63,9 @@ export const Phone: Story = {
59
63
  globals: phone,
60
64
  play: async ({ canvasElement }) => {
61
65
  const canvas = within(canvasElement);
62
- await expect(await canvas.findByText("Q3 planning")).toBeVisible();
66
+ await expect(
67
+ await canvas.findByText("Q3 planning", {}, routeLoad),
68
+ ).toBeVisible();
63
69
  },
64
70
  };
65
71
 
@@ -69,14 +75,16 @@ export const CaughtUp: Story = {
69
75
  },
70
76
  play: async ({ canvasElement }) => {
71
77
  const canvas = within(canvasElement);
72
- await expect(await canvas.findByText("You're caught up")).toBeVisible();
78
+ await expect(
79
+ await canvas.findByText("You're caught up", {}, routeLoad),
80
+ ).toBeVisible();
73
81
  },
74
82
  };
75
83
 
76
84
  export const FilteredToCategory: Story = {
77
85
  play: async ({ canvasElement }) => {
78
86
  const canvas = within(canvasElement);
79
- await canvas.findByText("Q3 planning");
87
+ await canvas.findByText("Q3 planning", {}, routeLoad);
80
88
  await openFilters(canvasElement);
81
89
  await pick(canvasElement, "Categories", "Newsletter");
82
90
  await waitFor(() =>
@@ -91,13 +99,15 @@ export const FilteredToCategory: Story = {
91
99
  export const WorkOnly: Story = {
92
100
  play: async ({ canvasElement }) => {
93
101
  const canvas = within(canvasElement);
94
- await canvas.findByText("Dinner on Saturday?");
102
+ await canvas.findByText("Dinner on Saturday?", {}, routeLoad);
95
103
  await openFilters(canvasElement);
96
104
  await pick(canvasElement, "Accounts", "alice.tan");
97
105
  await waitFor(() =>
98
106
  expect(canvas.queryByText("Dinner on Saturday?")).not.toBeInTheDocument(),
99
107
  );
100
- await expect(await canvas.findByText("Q3 planning")).toBeVisible();
108
+ await expect(
109
+ await canvas.findByText("Q3 planning", {}, routeLoad),
110
+ ).toBeVisible();
101
111
  },
102
112
  };
103
113
 
@@ -113,7 +123,7 @@ export const SelectAllThenNarrow: Story = {
113
123
  },
114
124
  play: async ({ canvasElement }) => {
115
125
  const canvas = within(canvasElement);
116
- await canvas.findByText("Q3 planning");
126
+ await canvas.findByText("Q3 planning", {}, routeLoad);
117
127
  await expect(
118
128
  await canvas.findByRole("button", { name: /^Show (all|\d+ more)/ }),
119
129
  ).toBeVisible();
@@ -146,7 +156,7 @@ export const SelectAllThenNarrow: Story = {
146
156
  export const ShiftArrowRange: Story = {
147
157
  play: async ({ canvasElement }) => {
148
158
  const canvas = within(canvasElement);
149
- await canvas.findByText("Q3 planning");
159
+ await canvas.findByText("Q3 planning", {}, routeLoad);
150
160
  rows(canvasElement)[0]?.focus();
151
161
  await userEvent.keyboard("j");
152
162
  await userEvent.keyboard("{Shift>}{ArrowDown}{ArrowDown}{/Shift}");
@@ -159,7 +169,7 @@ export const ShiftArrowRange: Story = {
159
169
  export const OpenWithJ: Story = {
160
170
  play: async ({ canvasElement }) => {
161
171
  const canvas = within(canvasElement);
162
- await canvas.findByText("Q3 planning");
172
+ await canvas.findByText("Q3 planning", {}, routeLoad);
163
173
  rows(canvasElement)[0]?.focus();
164
174
  await userEvent.keyboard("j");
165
175
  await waitFor(() =>
@@ -173,7 +183,7 @@ export const OpenWithJ: Story = {
173
183
  export const MultiSelect: Story = {
174
184
  play: async ({ canvasElement }) => {
175
185
  const canvas = within(canvasElement);
176
- await canvas.findByText("Q3 planning");
186
+ await canvas.findByText("Q3 planning", {}, routeLoad);
177
187
  const [first, , third] = rows(canvasElement);
178
188
  for (const row of [first, third]) {
179
189
  const toggle = row?.querySelector<HTMLElement>(
@@ -189,7 +199,7 @@ export const MultiSelectPhone: Story = {
189
199
  globals: phone,
190
200
  play: async ({ canvasElement }) => {
191
201
  const canvas = within(canvasElement);
192
- await canvas.findByText("Q3 planning");
202
+ await canvas.findByText("Q3 planning", {}, routeLoad);
193
203
  const first = rows(canvasElement)[0];
194
204
  if (first) await longPress(first);
195
205
  await expect(await canvas.findByText("1 message selected")).toBeVisible();
@@ -201,7 +211,7 @@ export const Search: Story = {
201
211
  play: async ({ canvasElement }) => {
202
212
  const canvas = within(canvasElement);
203
213
  await expect(
204
- await canvas.findByText("Signed lease attached"),
214
+ await canvas.findByText("Signed lease attached", {}, routeLoad),
205
215
  ).toBeVisible();
206
216
  await expect(canvas.queryByText("Dinner on Saturday?")).toBeNull();
207
217
  },
@@ -211,7 +221,7 @@ export const SearchWithChips: Story = {
211
221
  args: { url: "/mail/brief?q=your" },
212
222
  play: async ({ canvasElement }) => {
213
223
  const canvas = within(canvasElement);
214
- await canvas.findByText("Signed lease attached");
224
+ await canvas.findByText("Signed lease attached", {}, routeLoad);
215
225
  await openFilters(canvasElement);
216
226
  await pick(canvasElement, "Attributes", "Unread");
217
227
  await waitFor(() =>
@@ -226,9 +236,11 @@ export const SearchOfChipsOnly: Story = {
226
236
  args: { url: "/mail/brief?q=is:unread" },
227
237
  play: async ({ canvasElement }) => {
228
238
  const canvas = within(canvasElement);
229
- const offer = await canvas.findByRole("button", {
230
- name: "Make this a filter",
231
- });
239
+ const offer = await canvas.findByRole(
240
+ "button",
241
+ { name: "Make this a filter" },
242
+ routeLoad,
243
+ );
232
244
  await userEvent.click(offer);
233
245
  await expect(
234
246
  await canvas.findByText(/isn't a filter condition/, { selector: "p" }),
@@ -240,7 +252,9 @@ export const SearchHitOutsideTheBrief: Story = {
240
252
  args: { url: "/mail/brief?q=outline" },
241
253
  play: async ({ canvasElement }) => {
242
254
  const canvas = within(canvasElement);
243
- await userEvent.click(await canvas.findByText("Conference talk outline"));
255
+ await userEvent.click(
256
+ await canvas.findByText("Conference talk outline", {}, routeLoad),
257
+ );
244
258
  await waitFor(() =>
245
259
  expect(
246
260
  canvas.getAllByText("Here is the rough structure for the keynote."),
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Enter opens the row the keyboard cursor is on, even while the reading pane is
3
+ * still catching up to an earlier cursor move.
4
+ *
5
+ * On desktop the pane follows the cursor: a `j` opens its row a beat later. That
6
+ * open is a navigation, and the list only learns of it when the route hands the
7
+ * opened row back as `selectedMessageId`. If the user pressed `j` again in
8
+ * between, the late arrival used to drag the cursor back to the row it opened,
9
+ * so Enter opened the earlier row instead of the one under the cursor.
10
+ *
11
+ * The host below holds `selectedMessageId` back on purpose, which is what makes
12
+ * that window as wide as the test needs: the URL moves at once, the list hears
13
+ * about it only when the test releases it.
14
+ */
15
+
16
+ import assert from "node:assert/strict";
17
+ import { afterEach, describe, it } from "node:test";
18
+ import {
19
+ type AnyRouter,
20
+ createMemoryHistory,
21
+ createRootRoute,
22
+ createRoute,
23
+ createRouter,
24
+ Outlet,
25
+ RouterProvider,
26
+ } from "@tanstack/react-router";
27
+ import { act, createElement, createRef, useState } from "react";
28
+ import { createDomHarness, type DomHarness } from "@/test-support/dom";
29
+ import { makeAccount, makeThreadMessage } from "@/test-support/fixtures";
30
+ import { type HttpMock, mockFetch } from "@/test-support/http";
31
+ import { MessageList, type MessageListCommands } from "./MessageList";
32
+
33
+ const MAILBOX_ID = "mbx-inbox";
34
+ const LIST_PATH = `/mail/${MAILBOX_ID}`;
35
+
36
+ const threads = ["msg-a", "msg-b", "msg-c"].map((messageId) =>
37
+ makeThreadMessage({
38
+ messageId,
39
+ threadId: `thread-${messageId}`,
40
+ mailboxId: MAILBOX_ID,
41
+ }),
42
+ );
43
+ const [FIRST, SECOND] = threads;
44
+
45
+ const openPath = (messageId: string): string =>
46
+ `${LIST_PATH}/thread-${messageId}/${messageId}`;
47
+
48
+ let harness: DomHarness | undefined;
49
+ let http: HttpMock | undefined;
50
+
51
+ afterEach(() => {
52
+ harness?.close();
53
+ harness = undefined;
54
+ http?.restore();
55
+ http = undefined;
56
+ });
57
+
58
+ // The router reads `self` at construction; the shared jsdom globals stop at
59
+ // `window`.
60
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
61
+
62
+ interface Mounted {
63
+ mounted: DomHarness;
64
+ router: AnyRouter;
65
+ commands: () => MessageListCommands;
66
+ cursor: () => string | undefined;
67
+ releaseOpen: (messageId: string) => void;
68
+ }
69
+
70
+ const mount = async (): Promise<Mounted> => {
71
+ http = mockFetch((call) => {
72
+ if (call.path.endsWith("/config")) {
73
+ return { accounts: [makeAccount({ accountId: "acc-1" })] };
74
+ }
75
+ return { items: [] };
76
+ });
77
+
78
+ const commandsRef = createRef<MessageListCommands | null>();
79
+ let cursor: string | undefined;
80
+ let setSelected: ((messageId: string | undefined) => void) | undefined;
81
+
82
+ const Host = () => {
83
+ const [selected, set] = useState<string | undefined>(undefined);
84
+ setSelected = set;
85
+ return createElement(
86
+ "div",
87
+ null,
88
+ createElement(MessageList, {
89
+ mailboxId: MAILBOX_ID,
90
+ accountId: "acc-1",
91
+ threads,
92
+ selectedMessageId: selected,
93
+ isLoading: false,
94
+ listTitle: "Inbox",
95
+ commandsRef,
96
+ onTriageContextChange: (context) => {
97
+ cursor = context.focusedMessageId;
98
+ },
99
+ }),
100
+ createElement(Outlet),
101
+ );
102
+ };
103
+
104
+ const rootRoute = createRootRoute({ component: Outlet });
105
+ const mailRoute = createRoute({
106
+ getParentRoute: () => rootRoute,
107
+ path: "/mail",
108
+ validateSearch: (search: Record<string, unknown>) => search,
109
+ component: Outlet,
110
+ });
111
+ const mailboxRoute = createRoute({
112
+ getParentRoute: () => mailRoute,
113
+ path: "$mailboxId",
114
+ component: Host,
115
+ });
116
+ const listRoute = createRoute({
117
+ getParentRoute: () => mailboxRoute,
118
+ path: "/",
119
+ });
120
+ const threadRoute = createRoute({
121
+ getParentRoute: () => mailboxRoute,
122
+ path: "$threadId",
123
+ });
124
+ const messageRoute = createRoute({
125
+ getParentRoute: () => threadRoute,
126
+ path: "$messageId",
127
+ });
128
+ const router = createRouter({
129
+ routeTree: rootRoute.addChildren([
130
+ mailRoute.addChildren([
131
+ mailboxRoute.addChildren([
132
+ listRoute,
133
+ threadRoute.addChildren([messageRoute]),
134
+ ]),
135
+ ]),
136
+ ]),
137
+ history: createMemoryHistory({ initialEntries: [LIST_PATH] }),
138
+ }) as unknown as AnyRouter;
139
+ await router.load();
140
+
141
+ const mounted = createDomHarness({ viewportWidth: 1440 });
142
+ harness = mounted;
143
+ mounted.renderApp(createElement(RouterProvider, { router }));
144
+ await mounted.waitFor(
145
+ () => commandsRef.current !== null,
146
+ "the list published its commands",
147
+ );
148
+
149
+ return {
150
+ mounted,
151
+ router,
152
+ commands: () => {
153
+ const commands = commandsRef.current;
154
+ assert.ok(commands, "the list is mounted");
155
+ return commands;
156
+ },
157
+ cursor: () => cursor,
158
+ releaseOpen: (messageId) => act(() => setSelected?.(messageId)),
159
+ };
160
+ };
161
+
162
+ describe("Enter after a cursor move the reading pane has not caught up to", () => {
163
+ it("keeps the cursor on the newer row and opens that row", async () => {
164
+ const { mounted, router, commands, cursor, releaseOpen } = await mount();
165
+
166
+ act(() => commands().focusNext());
167
+ await mounted.waitFor(
168
+ () => router.state.location.pathname === openPath(FIRST.messageId),
169
+ "the pane followed the cursor onto the first row",
170
+ );
171
+
172
+ act(() => commands().focusNext());
173
+ await mounted.flush();
174
+ assert.equal(cursor(), SECOND.messageId, "j moved the cursor on");
175
+
176
+ releaseOpen(FIRST.messageId);
177
+ await mounted.flush();
178
+
179
+ assert.equal(
180
+ cursor(),
181
+ SECOND.messageId,
182
+ "the late open dragged the cursor back to the row it opened",
183
+ );
184
+
185
+ const pushesBefore = router.history.length;
186
+ act(() => commands().openFocused());
187
+ await mounted.waitFor(
188
+ () => router.history.length > pushesBefore,
189
+ "Enter opened a row",
190
+ );
191
+ assert.equal(
192
+ router.state.location.pathname,
193
+ openPath(SECOND.messageId),
194
+ "Enter opened a row other than the one under the cursor",
195
+ );
196
+ });
197
+ });
@@ -56,10 +56,8 @@ const intelligence: IntelligenceData = {
56
56
  claimedBrand: "Mondial Relay",
57
57
  summary:
58
58
  "The display name claims “Mondial Relay”, but the message was sent and signed by a personal gmail.example mailbox — not mondialrelay.fr.",
59
- similarCount: 15,
60
59
  },
61
60
  category: { value: "automated" },
62
- similar: [],
63
61
  };
64
62
 
65
63
  interface Message {
@@ -0,0 +1,44 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { HttpResponse, http } from "msw";
3
+ import { expect, within } from "storybook/test";
4
+ import { AppStory } from "@/mocks/story-frame/AppStory";
5
+ import { composeWorld, messageUrl } from "@/mocks/story-frame/compose-world";
6
+ import { mailHandlers } from "@/mocks/story-frame/mail-handlers";
7
+
8
+ const world = composeWorld();
9
+
10
+ const goneFromServer = http.get("/api/messages/msg-q3-planning", () =>
11
+ HttpResponse.json(
12
+ {
13
+ code: "message_gone_from_server",
14
+ message: "This message is no longer on the mail server.",
15
+ details: { messageId: "msg-q3-planning" },
16
+ },
17
+ { status: 404 },
18
+ ),
19
+ );
20
+
21
+ const meta = {
22
+ title: "Playground/Shipped/Mail/Message gone from server",
23
+ component: AppStory,
24
+ args: { url: messageUrl("q3-planning") },
25
+ parameters: {
26
+ layout: "fullscreen",
27
+ msw: { handlers: [goneFromServer, ...mailHandlers(world)] },
28
+ },
29
+ } satisfies Meta<typeof AppStory>;
30
+ export default meta;
31
+
32
+ type Story = StoryObj<typeof meta>;
33
+
34
+ export const MovedOrDeletedElsewhere: Story = {
35
+ play: async () => {
36
+ const page = within(document.body);
37
+ await page.findAllByText("Q3 planning");
38
+ await expect(
39
+ await page.findByText(
40
+ "This message is no longer on the mail server. It was moved or deleted in another mail app.",
41
+ ),
42
+ ).toBeVisible();
43
+ },
44
+ };