@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
@@ -91,7 +91,6 @@ import { useRefreshControl } from "@/hooks/useRefreshControl";
91
91
  import { useRescueCandidates } from "@/hooks/useRescueCandidates";
92
92
  import { useResultCount } from "@/hooks/useResultCount";
93
93
  import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
94
- import { useSemanticSearch } from "@/hooks/useSemanticSearch";
95
94
  import { useThreadActions } from "@/hooks/useThreadActions";
96
95
  import { useThreadMessageIds } from "@/hooks/useThreadMessageIds";
97
96
  import { useThreadRow } from "@/hooks/useThreadRow";
@@ -116,10 +115,7 @@ import { isRescueCandidate } from "@/lib/rescue-candidates";
116
115
  import { recordRescueSentToJunk } from "@/lib/rescue-telemetry";
117
116
  import { shouldRequestResultCount } from "@/lib/result-count";
118
117
  import { normalizeSearchQuery } from "@/lib/search-query";
119
- import {
120
- relatedSearchResults,
121
- threadToSearchResult,
122
- } from "@/lib/search-result";
118
+ import { threadToSearchResult } from "@/lib/search-result";
123
119
  import { parseSearchTokens } from "@/lib/search-tokens";
124
120
  import { useTelemetry } from "@/lib/telemetry-context";
125
121
  import {
@@ -961,33 +957,14 @@ function MailboxList() {
961
957
  threads.map((thread) => threadToSearchResult(thread, resultFolderIndex)),
962
958
  [threads, resultFolderIndex],
963
959
  );
964
- // The route scopes this view and the top bar's chip says so, so every engine
965
- // here respects it: the literal engine searches this mailbox, and the
966
- // semantic engine takes the same `mailboxId`. No chip means global; a chip
967
- // means nothing on the route reaches past it. Results are deduped by thread,
968
- // so a thread never shows in both sections.
969
- const { hits: semanticHits, isLoading: relatedLoading } = useSemanticSearch({
970
- mailboxId,
971
- filterCategory,
972
- });
973
- const relatedResults = useMemo(
974
- () =>
975
- relatedSearchResults(
976
- semanticHits,
977
- threads.map((t) => t.threadId),
978
- resultFolderIndex,
979
- ),
980
- [semanticHits, threads, resultFolderIndex],
981
- );
982
- // The two-engine results panel, whose semantic hits are in no list at all —
983
- // each one carries the thread it belongs to.
960
+ // The results panel; each result carries the thread it belongs to.
984
961
  const handleSelectSearchResult = useCallback(
985
962
  (result: SearchResult) => {
986
963
  const threadId =
987
964
  result.threadId ??
988
965
  threads.find((thread) => thread.messageId === result.id)?.threadId;
989
966
  if (!threadId) return;
990
- // Both sections are scoped to this mailbox, so a result's own mailbox is
967
+ // The search is scoped to this mailbox, so a result's own mailbox is
991
968
  // normally this one; keep reading it off the result so a row can never
992
969
  // open under a mailbox it does not belong to.
993
970
  openThread(
@@ -1096,13 +1073,11 @@ function MailboxList() {
1096
1073
  onClearFilters={onClearFilters}
1097
1074
  searchResults={searchResults}
1098
1075
  searchLoading={isLoading}
1099
- relatedResults={relatedResults}
1100
- relatedLoading={relatedLoading}
1101
1076
  onSelectSearchResult={handleSelectSearchResult}
1102
1077
  // A committed mailbox search renders in the body's own `MessageList`
1103
1078
  // (its threads filter to the results), so the multi-select toolbar and
1104
1079
  // the "Select all N matching" escalation are reachable on desktop (#212).
1105
- // The typing/uncommitted state still shows the two-engine panel.
1080
+ // The typing/uncommitted state still shows the results panel.
1106
1081
  searchResultsInBody
1107
1082
  refreshControl={refreshControl}
1108
1083
  >
@@ -1204,7 +1179,6 @@ function MailboxReading() {
1204
1179
  isOpen={intelligence.drawerOpen}
1205
1180
  onClose={intelligence.closeDrawer}
1206
1181
  thread={selectedThread}
1207
- mailboxId={mailboxId}
1208
1182
  accountId={mailboxAccountId}
1209
1183
  onAfterOptimisticRemove={handleDeselectIfRemoved}
1210
1184
  onReplyWithText={replyWithText}
@@ -1221,7 +1195,6 @@ function MailboxReading() {
1221
1195
  function MailboxIntelligence() {
1222
1196
  const replyWithText = useReplyWithText();
1223
1197
  const {
1224
- mailboxId,
1225
1198
  mailboxAccountId,
1226
1199
  selectedThread,
1227
1200
  onToggleIntelligence,
@@ -1233,7 +1206,6 @@ function MailboxIntelligence() {
1233
1206
  <IntelligencePane
1234
1207
  onClose={onToggleIntelligence}
1235
1208
  thread={selectedThread}
1236
- mailboxId={mailboxId}
1237
1209
  accountId={mailboxAccountId}
1238
1210
  onAfterOptimisticRemove={handleDeselectIfRemoved}
1239
1211
  onReplyWithText={replyWithText}
@@ -1249,7 +1221,6 @@ function MailboxIntelligence() {
1249
1221
  function MailboxPhone() {
1250
1222
  const replyWithText = useReplyWithText();
1251
1223
  const {
1252
- mailboxId,
1253
1224
  mailboxAccountId,
1254
1225
  selectedThread,
1255
1226
  conversation,
@@ -1288,7 +1259,6 @@ function MailboxPhone() {
1288
1259
  isOpen={drawer.isOpen}
1289
1260
  onClose={drawer.close}
1290
1261
  thread={selectedThread}
1291
- mailboxId={mailboxId}
1292
1262
  accountId={mailboxAccountId}
1293
1263
  onAfterOptimisticRemove={handleDeselectIfRemoved}
1294
1264
  onReplyWithText={replyWithText}
@@ -2,6 +2,12 @@ import {
2
2
  messageBulkOperationsUpdateFlagsMutation,
3
3
  threadDetailOperationsListThreadMessagesQueryKey,
4
4
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
+ import {
6
+ DropdownMenu,
7
+ DropdownMenuItem,
8
+ DropdownMenuSeparator,
9
+ ErrorState,
10
+ } from "@remit/ui";
5
11
  import { useMutation, useQueryClient } from "@tanstack/react-query";
6
12
  import {
7
13
  BadgeCheck,
@@ -12,13 +18,7 @@ import {
12
18
  Trash2,
13
19
  } from "lucide-react";
14
20
  import { useCallback } from "react";
15
- import {
16
- DropdownMenu,
17
- DropdownMenuItem,
18
- DropdownMenuSeparator,
19
- } from "@/components/ui/DropdownMenu";
20
21
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
21
- import { ErrorState } from "@/components/ui/ErrorState";
22
22
  import { formatErrorDetail } from "@/components/ui/error-banners";
23
23
  import { useDeleteMessages } from "@/hooks/useDeleteMessages";
24
24
  import { setReadOnItems } from "@/hooks/useMarkAsRead";
@@ -3,6 +3,7 @@ import type {
3
3
  RemitImapBodyPartResponse,
4
4
  RemitImapDescribeMessageResponse,
5
5
  } from "@remit/api-http-client/types.gen.ts";
6
+ import { encapsulatedParts } from "@remit/data-ports/message-parts";
6
7
  import { type AttachmentDownloadState, AttachmentList } from "@remit/ui";
7
8
  import { useQueryClient } from "@tanstack/react-query";
8
9
  import { useMemo, useState } from "react";
@@ -20,6 +21,7 @@ import {
20
21
  type MessageAttachment,
21
22
  selectMessageAttachments,
22
23
  } from "@/lib/message-attachments";
24
+ import { MessageBody } from "./MessageBody";
23
25
 
24
26
  interface MessageAttachmentsProps {
25
27
  messageId: string;
@@ -38,6 +40,9 @@ const IDLE: AttachmentDownloadState = { status: "idle" };
38
40
  * Download state is per row and lives here rather than in a query: a download is
39
41
  * a one-shot user action with no cached result to hold, and the bytes are handed
40
42
  * to the browser rather than rendered.
43
+ *
44
+ * A forwarded message also opens in place: its own parts render through the
45
+ * same body and attachment components as the message carrying it.
41
46
  */
42
47
  export const MessageAttachments = ({
43
48
  messageId,
@@ -50,6 +55,7 @@ export const MessageAttachments = ({
50
55
  const [downloads, setDownloads] = useState<
51
56
  Record<string, AttachmentDownloadState>
52
57
  >({});
58
+ const [openedId, setOpenedId] = useState<string | null>(null);
53
59
 
54
60
  const attachments = useMemo(
55
61
  () => selectMessageAttachments(bodyParts ?? []),
@@ -136,11 +142,20 @@ export const MessageAttachments = ({
136
142
  const hasUnlistedAttachment =
137
143
  hasAttachment && bodyParts !== undefined && attachments.length === 0;
138
144
 
145
+ const openedMessage = attachments.find(
146
+ (attachment) =>
147
+ attachment.kind === "message" && attachment.bodyPartId === openedId,
148
+ );
149
+ const openedParts = openedMessage
150
+ ? encapsulatedParts(bodyParts ?? [], openedMessage.partPath)
151
+ : [];
152
+
139
153
  return (
140
154
  <AttachmentList
141
155
  className={className}
142
156
  attachments={attachments.map((attachment) => ({
143
157
  attachmentId: attachment.bodyPartId,
158
+ kind: attachment.kind,
144
159
  filename: attachment.filename,
145
160
  typeLabel: attachment.typeLabel,
146
161
  sizeOctets: attachment.sizeOctets,
@@ -148,6 +163,26 @@ export const MessageAttachments = ({
148
163
  }))}
149
164
  onDownload={download}
150
165
  hasUnlistedAttachment={hasUnlistedAttachment}
166
+ onToggleOpen={(bodyPartId) =>
167
+ setOpenedId((current) => (current === bodyPartId ? null : bodyPartId))
168
+ }
169
+ opened={
170
+ openedMessage
171
+ ? {
172
+ attachmentId: openedMessage.bodyPartId,
173
+ content: (
174
+ <>
175
+ <MessageBody bodyParts={openedParts} messageId={messageId} />
176
+ <MessageAttachments
177
+ messageId={messageId}
178
+ bodyParts={openedParts}
179
+ className="mt-4"
180
+ />
181
+ </>
182
+ ),
183
+ }
184
+ : undefined
185
+ }
151
186
  />
152
187
  );
153
188
  };
@@ -20,6 +20,7 @@ import { MessageBodyErrorBanner } from "./MessageBodyErrorBanner";
20
20
  */
21
21
  export interface MessageBodyPart {
22
22
  bodyPartId: string;
23
+ partPath: string;
23
24
  mediaType: string;
24
25
  mediaSubtype: string;
25
26
  contentId?: string;
@@ -1,6 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
- import { BodyFetchError } from "@/hooks/useMessageBodyContent";
4
3
  import {
5
4
  contentForReason,
6
5
  extractFallbackDetail,
@@ -8,18 +7,6 @@ import {
8
7
  } from "./message-body-error-banner-content";
9
8
 
10
9
  describe("contentForReason — diagnostic banner copy per failure mode (#401)", () => {
11
- it("renders a session-expired title for auth failures (Lambda@Edge 401/403 with x-remit-403-reason header)", () => {
12
- const content = contentForReason("auth", "ignored fallback");
13
- assert.match(content.title, /session/i);
14
- assert.match(content.detail, /sign in/i);
15
- });
16
-
17
- it("renders a body-missing-in-storage title for bare S3 403/404 (no edge header — OAC blocks list so missing keys 403)", () => {
18
- const content = contentForReason("body-missing", "ignored fallback");
19
- assert.match(content.title, /missing in storage/i);
20
- assert.match(content.detail, /reconcile|sync|support/i);
21
- });
22
-
23
10
  it("renders a generic title for other 4xx/5xx and falls back to the error's own message in the detail", () => {
24
11
  const content = contentForReason("generic", "Network connection lost");
25
12
  assert.match(content.title, /couldn.?t load/i);
@@ -37,26 +24,9 @@ describe("contentForReason — diagnostic banner copy per failure mode (#401)",
37
24
  assert.match(content.title, /couldn.?t load/i);
38
25
  assert.match(content.detail, /shell|infrastructure|report/i);
39
26
  });
40
-
41
- it("never reuses the auth copy for body-missing — these must be visibly distinct so the user knows which failure they're facing (#401 acceptance)", () => {
42
- const auth = contentForReason("auth", "x");
43
- const missing = contentForReason("body-missing", "x");
44
- assert.notEqual(auth.title, missing.title);
45
- assert.notEqual(auth.detail, missing.detail);
46
- });
47
27
  });
48
28
 
49
29
  describe("extractReason — pulls the discriminated reason off a BodyFetchError, falls back to generic", () => {
50
- it("returns the reason from a BodyFetchError instance", () => {
51
- const err = new BodyFetchError("body-missing", "missing", 403);
52
- assert.equal(extractReason(err), "body-missing");
53
- });
54
-
55
- it("returns the reason from a BodyFetchError(auth)", () => {
56
- const err = new BodyFetchError("auth", "Invalid id_token", 401);
57
- assert.equal(extractReason(err), "auth");
58
- });
59
-
60
30
  it("falls back to generic for a plain Error (e.g. a network error before fetch returned)", () => {
61
31
  assert.equal(extractReason(new Error("offline")), "generic");
62
32
  });
@@ -4,13 +4,21 @@ import type {
4
4
  RemitImapThreadMessageResponse,
5
5
  } from "@remit/api-http-client/types.gen.ts";
6
6
  import type { ThreadMessageData } from "@remit/ui";
7
- import { AddressList, CollapsedMessage, cn, ExpandedMessage } from "@remit/ui";
7
+ import {
8
+ AddressList,
9
+ CollapsedMessage,
10
+ cn,
11
+ EmptyState,
12
+ ErrorState,
13
+ ExpandedMessage,
14
+ } from "@remit/ui";
8
15
  import { useQuery } from "@tanstack/react-query";
9
16
  import { BadgeCheck, Paperclip, Star } from "lucide-react";
10
17
  import { useState } from "react";
11
- import { EmptyState } from "@/components/ui/EmptyState";
12
- import { ErrorState } from "@/components/ui/ErrorState";
13
- import { isMessageNotFoundError } from "@/components/ui/error-banners";
18
+ import {
19
+ isMessageGoneFromServerError,
20
+ isMessageNotFoundError,
21
+ } from "@/components/ui/error-banners";
14
22
  import { toDisplayCategory } from "@/lib/display-category";
15
23
  import { formatDatePreset } from "@/lib/format";
16
24
  import { AutoMovedIndicator } from "./AutoMovedIndicator";
@@ -310,6 +318,10 @@ const ExpandedCard = ({
310
318
  <div className="h-4 bg-surface-sunken rounded w-3/4" />
311
319
  <div className="h-4 bg-surface-sunken rounded w-1/2" />
312
320
  </div>
321
+ ) : isError && isMessageGoneFromServerError(error) ? (
322
+ <div className="mt-3">
323
+ <EmptyState message="This message is no longer on the mail server. It was moved or deleted in another mail app." />
324
+ </div>
313
325
  ) : isError && isMessageNotFoundError(error) ? (
314
326
  <div className="mt-3">
315
327
  <EmptyState message="This message has been deleted" />
@@ -1,8 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { readFileSync } from "node:fs";
3
- import { dirname, resolve } from "node:path";
4
2
  import { describe, it } from "node:test";
5
- import { fileURLToPath } from "node:url";
6
3
  import { runEndingBanner } from "@/lib/bulk-action-copy";
7
4
  import type { BulkRunOutcome } from "@/lib/bulk-actions";
8
5
  import { type DeleteOutcome, deleteExpunges } from "@/lib/format";
@@ -13,22 +10,10 @@ import { type DeleteOutcome, deleteExpunges } from "@/lib/format";
13
10
  * the run with no screen of its own — so the list says what it reached, whether
14
11
  * that was everything or a hundred out of three thousand.
15
12
  *
16
- * Which ending gets which banner is `runEndingBanner`, and it is asserted here
17
- * by its result. It used to be asserted by matching the source text of
18
- * `reportRunOutcome`, which meant the rule held only as long as nobody moved
19
- * those lines — adding an argument to the completion call broke all three
20
- * cases while the behaviour they protect was intact. The one fact that still
21
- * cannot be read off a result is the wiring: that the wizard is handed
22
- * somewhere to report an ending after it has closed. That stays a source read,
23
- * because reaching it otherwise means mounting the list's virtualizer, router
24
- * and data hooks around a run that has already finished.
25
- *
26
- * Which runs reach this seam at all is `SelectionWizardHost.run-exit.test.ts`.
13
+ * Which ending gets which banner is `runEndingBanner`, asserted here by its
14
+ * result.
27
15
  */
28
16
 
29
- const here = dirname(fileURLToPath(import.meta.url));
30
- const source = readFileSync(resolve(here, "MessageList.tsx"), "utf8");
31
-
32
17
  const expunging: DeleteOutcome[] = ["permanent", "unconfirmed"];
33
18
 
34
19
  const ended = (over: Partial<BulkRunOutcome> = {}): BulkRunOutcome => ({
@@ -40,21 +25,6 @@ const ended = (over: Partial<BulkRunOutcome> = {}): BulkRunOutcome => ({
40
25
  });
41
26
 
42
27
  describe("reporting how a run ended", () => {
43
- it("hands the wizard somewhere to report an ending it can no longer show", () => {
44
- const host = source.match(/<SelectionWizardHost[\s\S]*?\/>/)?.[0] ?? "";
45
- assert.match(host, /onRunEnded=\{reportRunOutcome\}/);
46
- assert.match(
47
- source,
48
- /const reportRunOutcome = useCallback\(/,
49
- "the list reports no run outcome",
50
- );
51
- assert.match(
52
- source,
53
- /runEndingBanner\(kind, matched, outcome, deleteOutcome\)/,
54
- "and the ending it reports is the one runEndingBanner decides",
55
- );
56
- });
57
-
58
28
  it("names both endings: what it covered, and what it stopped short of", () => {
59
29
  const stopped = runEndingBanner(
60
30
  "delete",
@@ -115,17 +85,3 @@ describe("reporting how a run ended", () => {
115
85
  });
116
86
  }
117
87
  });
118
-
119
- describe("the run the wizard leaves behind", () => {
120
- it("is stoppable from the wizard, which is not the same as closing it", () => {
121
- assert.match(source, /stop: escalation\.stop,/);
122
- });
123
-
124
- it("keeps reporting on the bar from the moment it starts", () => {
125
- assert.match(
126
- source,
127
- /escalation\.progress\?\.total \?\? selectionCount/,
128
- "a run with no batch finished yet has no denominator to show",
129
- );
130
- });
131
- });
@@ -4,6 +4,7 @@ import {
4
4
  cn,
5
5
  type Density,
6
6
  deriveIsMultiSelectMode,
7
+ formatErrorMessage,
7
8
  ListResultHeader,
8
9
  type MessageListFilter,
9
10
  MessageListLoadingMore,
@@ -20,7 +21,6 @@ import { useVirtualizer } from "@tanstack/react-virtual";
20
21
  import type { RefObject } from "react";
21
22
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
23
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
23
- import { formatErrorMessage } from "@/components/ui/ErrorState";
24
24
  import { useJunkMailbox } from "@/hooks/useArchiveMailbox";
25
25
  import { useDeleteOutcome } from "@/hooks/useDeleteOutcome";
26
26
  import {
@@ -501,9 +501,8 @@ export const MessageList = ({
501
501
  const orderedIds = useMemo(() => threads.map((t) => t.messageId), [threads]);
502
502
 
503
503
  // The checked rows as the wizard reads them: the sample it shows under every
504
- // screen that names a match, and the senders its widen falls back to on a
505
- // deployment with no vector pipeline. Read off the already-loaded thread rows,
506
- // so no extra round-trip.
504
+ // screen that names a match, and the senders its properties prefill reads.
505
+ // Read off the already-loaded thread rows, so no extra round-trip.
507
506
  const wizardSelection = useMemo<WizardSelectionMessage[]>(() => {
508
507
  const rows: WizardSelectionMessage[] = [];
509
508
  for (const thread of threads) {
@@ -673,8 +672,16 @@ export const MessageList = ({
673
672
  // and the selection toolbar — not a message — is what the user is looking at.
674
673
  // Suspended off desktop too, where the reading pane replaces the list
675
674
  // entirely, so following the cursor would throw the user off the list.
675
+ //
676
+ // The open lands a navigation later, and the cursor may have moved on by
677
+ // then. The open records which row it was for, so its arrival does not drag
678
+ // the cursor back off the row the user has since moved to.
679
+ const followOpenedRef = useRef<string | undefined>(undefined);
676
680
  const followFocusOpen = useCallback(
677
- (messageId: string) => openRow(messageId, { replace: true }),
681
+ (messageId: string) => {
682
+ followOpenedRef.current = messageId;
683
+ openRow(messageId, { replace: true });
684
+ },
678
685
  [openRow],
679
686
  );
680
687
  useFollowFocusOpen({
@@ -877,13 +884,18 @@ export const MessageList = ({
877
884
  // Opening a thread (click or Enter, anywhere) seeds the focus cursor onto it
878
885
  // so subsequent j/k continue from the open row — focus and open stay in
879
886
  // sync on open while remaining independent during scanning.
887
+ // A row the cursor itself opened is the exception: the cursor was already on
888
+ // it, and if the user has pressed j again since, Enter must act on the row
889
+ // they are on now rather than the one the reading pane caught up to.
880
890
  useEffect(() => {
881
- if (selectedMessageId) {
882
- // A thread going open is the list's own move, whatever opened it — a
883
- // deep link arrives with the row far down and has to be scrolled to.
884
- cursorMovedByPointerRef.current = false;
885
- setFocusedMessageId(selectedMessageId);
886
- }
891
+ if (!selectedMessageId) return;
892
+ const openedByCursor = followOpenedRef.current === selectedMessageId;
893
+ followOpenedRef.current = undefined;
894
+ if (openedByCursor) return;
895
+ // A thread going open is the list's own move, whatever opened it — a
896
+ // deep link arrives with the row far down and has to be scrolled to.
897
+ cursorMovedByPointerRef.current = false;
898
+ setFocusedMessageId(selectedMessageId);
887
899
  }, [selectedMessageId, setFocusedMessageId]);
888
900
 
889
901
  // Keep the focus cursor valid as the thread list changes (after delete /
@@ -51,11 +51,4 @@ describe("MessageRow semantics", () => {
51
51
  assert.match(html, /role="option"/);
52
52
  assert.match(html, /aria-selected/);
53
53
  });
54
-
55
- it("renders the row's sender, subject and snippet", () => {
56
- const html = render({ thread });
57
- assert.match(html, /Alex Rivera/);
58
- assert.match(html, /Quarterly numbers are in/);
59
- assert.match(html, /shared drive/);
60
- });
61
54
  });
@@ -2,6 +2,7 @@ import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@t
2
2
  import {
3
3
  Button,
4
4
  cn,
5
+ ErrorState,
5
6
  type FolderTreeNode,
6
7
  FolderTreePicker,
7
8
  PopoverMenuPortal,
@@ -18,7 +19,6 @@ import {
18
19
  useState,
19
20
  } from "react";
20
21
  import { Drawer } from "vaul";
21
- import { ErrorState } from "@/components/ui/ErrorState";
22
22
  import { useFolderAppointments } from "@/hooks/useArchiveMailbox";
23
23
  import { useCreateMailbox } from "@/hooks/useCreateMailbox";
24
24
  import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
@@ -1,6 +1,6 @@
1
1
  import { messageOperationsGetRawMessageOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import { ErrorState } from "@remit/ui";
2
3
  import { useQuery } from "@tanstack/react-query";
3
- import { ErrorState } from "@/components/ui/ErrorState";
4
4
 
5
5
  interface RawMessageViewProps {
6
6
  /** The message whose raw RFC822/MIME source to fetch and display. */
@@ -21,7 +21,6 @@ const conversion = (
21
21
  matchOperator: "all",
22
22
  droppedFacets: [],
23
23
  keptTerms: false,
24
- droppedSemantic: false,
25
24
  ...over,
26
25
  });
27
26