@remit/web-client 0.0.1

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 (298) hide show
  1. package/harness/build.mjs +84 -0
  2. package/harness/index.html +37 -0
  3. package/harness/vite-preset.ts +29 -0
  4. package/index.html +47 -0
  5. package/package.json +147 -0
  6. package/public/config.js +5 -0
  7. package/public/locales/de/common.json +1 -0
  8. package/public/locales/de/errors.json +1 -0
  9. package/public/locales/de/mail.json +13 -0
  10. package/public/locales/de/settings.json +1 -0
  11. package/public/locales/en/common.json +26 -0
  12. package/public/locales/en/errors.json +1 -0
  13. package/public/locales/en/mail.json +69 -0
  14. package/public/locales/en/settings.json +1 -0
  15. package/public/locales/fr/common.json +1 -0
  16. package/public/locales/fr/errors.json +1 -0
  17. package/public/locales/fr/mail.json +13 -0
  18. package/public/locales/fr/settings.json +1 -0
  19. package/public/locales/nl/common.json +1 -0
  20. package/public/locales/nl/errors.json +1 -0
  21. package/public/locales/nl/mail.json +13 -0
  22. package/public/locales/nl/settings.json +1 -0
  23. package/src/auth/AccountMenu.tsx +58 -0
  24. package/src/auth/AccountSession.tsx +19 -0
  25. package/src/auth/BetterAuthShell.tsx +198 -0
  26. package/src/auth/account-menu-mode.test.ts +64 -0
  27. package/src/auth/account-menu-mode.ts +28 -0
  28. package/src/auth/amplify-config.test.ts +158 -0
  29. package/src/auth/amplify-config.ts +60 -0
  30. package/src/auth/auth-error.test.ts +109 -0
  31. package/src/auth/auth-error.ts +107 -0
  32. package/src/auth/auth-interceptor.test.ts +86 -0
  33. package/src/auth/auth-interceptor.ts +23 -0
  34. package/src/auth/auth.css +334 -0
  35. package/src/auth/better-auth/BetterAuthAccount.tsx +25 -0
  36. package/src/auth/better-auth-config.ts +85 -0
  37. package/src/auth/better-auth-provider.tsx +20 -0
  38. package/src/auth/cognito/CognitoAccount.tsx +48 -0
  39. package/src/auth/cognito/CognitoShell.tsx +266 -0
  40. package/src/auth/cognito/cognito-token.test.ts +110 -0
  41. package/src/auth/cognito/cognito-token.ts +47 -0
  42. package/src/auth/cognito/cognito.css +21 -0
  43. package/src/auth/cognito-provider.tsx +22 -0
  44. package/src/auth/combined-provider.tsx +42 -0
  45. package/src/auth/composition.test.ts +136 -0
  46. package/src/auth/provider.tsx +63 -0
  47. package/src/auth/sign-out-visibility.test.ts +40 -0
  48. package/src/auth/sign-out-visibility.ts +10 -0
  49. package/src/auth/sign-up-disabled.test.ts +23 -0
  50. package/src/components/compose/AddressField.tsx +237 -0
  51. package/src/components/compose/ComposeBody.tsx +36 -0
  52. package/src/components/compose/ComposeForm.tsx +676 -0
  53. package/src/components/compose/ComposeProvider.tsx +133 -0
  54. package/src/components/compose/ComposeSmtpMissingBanner.tsx +49 -0
  55. package/src/components/compose/FromSelector.tsx +67 -0
  56. package/src/components/compose/FullCompose.tsx +82 -0
  57. package/src/components/compose/InlineCompose.tsx +29 -0
  58. package/src/components/compose/MobileComposeSheet.tsx +152 -0
  59. package/src/components/compose/PlateEditor.tsx +86 -0
  60. package/src/components/compose/PlateToolbar.tsx +110 -0
  61. package/src/components/compose/SubjectField.tsx +19 -0
  62. package/src/components/compose/draft-discard-banner.test.ts +76 -0
  63. package/src/components/compose/index.ts +6 -0
  64. package/src/components/compose/sanitize-quote-html.ts +23 -0
  65. package/src/components/layout/AppShellSkeleton.tsx +53 -0
  66. package/src/components/layout/ComposeFab.tsx +86 -0
  67. package/src/components/layout/Drawer.tsx +105 -0
  68. package/src/components/mail/AutoMovedIndicator.tsx +48 -0
  69. package/src/components/mail/BlockedImagesNotice.render.test.ts +65 -0
  70. package/src/components/mail/BlockedImagesNotice.tsx +41 -0
  71. package/src/components/mail/BriefPane.tsx +289 -0
  72. package/src/components/mail/ConversationView.tsx +462 -0
  73. package/src/components/mail/DailyBrief.tsx +451 -0
  74. package/src/components/mail/DraftsView.tsx +279 -0
  75. package/src/components/mail/FlaggedList.tsx +170 -0
  76. package/src/components/mail/FlaggedPane.tsx +217 -0
  77. package/src/components/mail/IntelligencePane.test.ts +123 -0
  78. package/src/components/mail/IntelligencePane.tsx +441 -0
  79. package/src/components/mail/MailListHeader.tsx +187 -0
  80. package/src/components/mail/MailNav.tsx +70 -0
  81. package/src/components/mail/MailSidebarAdapter.tsx +351 -0
  82. package/src/components/mail/MailViewChrome.tsx +95 -0
  83. package/src/components/mail/MailboxPane.tsx +1219 -0
  84. package/src/components/mail/MessageActionMenu.tsx +368 -0
  85. package/src/components/mail/MessageBody.tsx +207 -0
  86. package/src/components/mail/MessageBodyErrorBanner.render.test.ts +85 -0
  87. package/src/components/mail/MessageBodyErrorBanner.test.ts +86 -0
  88. package/src/components/mail/MessageBodyErrorBanner.tsx +90 -0
  89. package/src/components/mail/MessageCard.tsx +401 -0
  90. package/src/components/mail/MessageDetail.tsx +153 -0
  91. package/src/components/mail/MessageList.tsx +804 -0
  92. package/src/components/mail/MessageListItem.tsx +267 -0
  93. package/src/components/mail/MessageToolbar.render.test.ts +57 -0
  94. package/src/components/mail/MessageToolbar.tsx +166 -0
  95. package/src/components/mail/MobileMessageBar.tsx +142 -0
  96. package/src/components/mail/MoveToTrigger.tsx +250 -0
  97. package/src/components/mail/NavMenuButton.tsx +25 -0
  98. package/src/components/mail/OutboxPane.tsx +531 -0
  99. package/src/components/mail/PullToRefresh.tsx +24 -0
  100. package/src/components/mail/RawMessageView.tsx +84 -0
  101. package/src/components/mail/ReadingPaneEmpty.render.test.ts +42 -0
  102. package/src/components/mail/SelectionToolbar.tsx +139 -0
  103. package/src/components/mail/SpamRescue.render.test.ts +35 -0
  104. package/src/components/mail/SpamRescue.tsx +138 -0
  105. package/src/components/mail/SwipeableMessageRow.tsx +148 -0
  106. package/src/components/mail/message-body-error-banner-content.ts +87 -0
  107. package/src/components/mail/organize/OrganizeDialog.render.test.ts +37 -0
  108. package/src/components/mail/organize/OrganizeDialog.tsx +83 -0
  109. package/src/components/mail/organize/OrganizePanel.render.test.ts +102 -0
  110. package/src/components/mail/organize/OrganizePanel.tsx +344 -0
  111. package/src/components/onboarding/OnboardingWizard.tsx +1547 -0
  112. package/src/components/settings/AccountFormPanel.test.ts +72 -0
  113. package/src/components/settings/AccountFormPanel.tsx +942 -0
  114. package/src/components/settings/DangerZone.tsx +30 -0
  115. package/src/components/settings/DeleteAccountDialog.render.test.ts +47 -0
  116. package/src/components/settings/DeleteAccountDialog.tsx +224 -0
  117. package/src/components/settings/FiltersList.render.test.ts +71 -0
  118. package/src/components/settings/FiltersList.tsx +93 -0
  119. package/src/components/settings/account-form-helpers.test.ts +172 -0
  120. package/src/components/settings/account-form-helpers.ts +105 -0
  121. package/src/components/ui/AppVersion.tsx +50 -0
  122. package/src/components/ui/BugReportButton.tsx +45 -0
  123. package/src/components/ui/ConfirmDialog.tsx +125 -0
  124. package/src/components/ui/DropdownMenu.tsx +105 -0
  125. package/src/components/ui/EmptyState.tsx +11 -0
  126. package/src/components/ui/ErrorBanner.tsx +88 -0
  127. package/src/components/ui/ErrorBannerProvider.tsx +77 -0
  128. package/src/components/ui/ErrorBannerStack.tsx +34 -0
  129. package/src/components/ui/ErrorState.tsx +80 -0
  130. package/src/components/ui/FatalErrorOverlay.render.test.ts +66 -0
  131. package/src/components/ui/FatalErrorOverlay.tsx +151 -0
  132. package/src/components/ui/KeyboardShortcutsModal.tsx +120 -0
  133. package/src/components/ui/SlidePanel.tsx +72 -0
  134. package/src/components/ui/error-banners.test.ts +207 -0
  135. package/src/components/ui/error-banners.ts +111 -0
  136. package/src/hooks/queries/keys.ts +38 -0
  137. package/src/hooks/useArchiveMailbox.ts +104 -0
  138. package/src/hooks/useAutoMovedBadge.ts +77 -0
  139. package/src/hooks/useCurrentMailboxName.ts +99 -0
  140. package/src/hooks/useDebouncedValue.ts +12 -0
  141. package/src/hooks/useDeleteMessages.test.ts +103 -0
  142. package/src/hooks/useDeleteMessages.ts +226 -0
  143. package/src/hooks/useFilters.ts +112 -0
  144. package/src/hooks/useIntelligenceData.test.ts +269 -0
  145. package/src/hooks/useIntelligenceData.ts +284 -0
  146. package/src/hooks/useIsDark.ts +27 -0
  147. package/src/hooks/useKeyboardNavigation.ts +192 -0
  148. package/src/hooks/useLayoutTier.test.ts +47 -0
  149. package/src/hooks/useLayoutTier.ts +44 -0
  150. package/src/hooks/useLongPress.test.ts +291 -0
  151. package/src/hooks/useLongPress.ts +87 -0
  152. package/src/hooks/useMailboxAccount.ts +62 -0
  153. package/src/hooks/useMailboxNameIndex.ts +32 -0
  154. package/src/hooks/useMarkAsRead.test.ts +165 -0
  155. package/src/hooks/useMarkAsRead.ts +319 -0
  156. package/src/hooks/useMediaQuery.ts +39 -0
  157. package/src/hooks/useMessageBodyContent.test.ts +370 -0
  158. package/src/hooks/useMessageBodyContent.ts +380 -0
  159. package/src/hooks/useMoveMessages.test.ts +63 -0
  160. package/src/hooks/useMoveMessages.ts +221 -0
  161. package/src/hooks/useOrganizeJob.ts +90 -0
  162. package/src/hooks/useOrganizePreview.ts +40 -0
  163. package/src/hooks/useRescueCandidates.test.ts +217 -0
  164. package/src/hooks/useRescueCandidates.ts +48 -0
  165. package/src/hooks/useSaveDraft.ts +118 -0
  166. package/src/hooks/useSelection.test.ts +75 -0
  167. package/src/hooks/useSelection.ts +211 -0
  168. package/src/hooks/useSemanticSearch.ts +94 -0
  169. package/src/hooks/useSignature.ts +69 -0
  170. package/src/hooks/useStaleAccountSync.test.ts +211 -0
  171. package/src/hooks/useStaleAccountSync.ts +246 -0
  172. package/src/hooks/useSwipeNavigation.test.ts +35 -0
  173. package/src/hooks/useSwipeNavigation.ts +99 -0
  174. package/src/hooks/useToggleStar.ts +187 -0
  175. package/src/hooks/useToggleTrusted.test.ts +102 -0
  176. package/src/hooks/useToggleTrusted.ts +161 -0
  177. package/src/hooks/useTriageKeyboard.ts +103 -0
  178. package/src/hooks/useTriggerSync.test.ts +24 -0
  179. package/src/hooks/useTriggerSync.ts +58 -0
  180. package/src/hooks/useUpdateAddressFlags.ts +125 -0
  181. package/src/hooks/useVisualViewport.test.ts +127 -0
  182. package/src/hooks/useVisualViewport.ts +51 -0
  183. package/src/index.css +19 -0
  184. package/src/lib/account-order.test.ts +84 -0
  185. package/src/lib/account-order.ts +13 -0
  186. package/src/lib/adjacent-message.test.ts +43 -0
  187. package/src/lib/adjacent-message.ts +20 -0
  188. package/src/lib/api.ts +82 -0
  189. package/src/lib/app-info.ts +15 -0
  190. package/src/lib/auto-moved.test.ts +140 -0
  191. package/src/lib/auto-moved.ts +68 -0
  192. package/src/lib/autodiscovery.test.ts +94 -0
  193. package/src/lib/autodiscovery.ts +354 -0
  194. package/src/lib/brief.test.ts +366 -0
  195. package/src/lib/brief.ts +180 -0
  196. package/src/lib/bug-report.test.ts +213 -0
  197. package/src/lib/bug-report.ts +176 -0
  198. package/src/lib/client.ts +36 -0
  199. package/src/lib/console-errors.test.ts +64 -0
  200. package/src/lib/console-errors.ts +92 -0
  201. package/src/lib/conversation-target.test.ts +68 -0
  202. package/src/lib/conversation-target.ts +59 -0
  203. package/src/lib/display-category.ts +20 -0
  204. package/src/lib/drafts.test.ts +241 -0
  205. package/src/lib/drafts.ts +121 -0
  206. package/src/lib/error-classifier.test.ts +133 -0
  207. package/src/lib/error-classifier.ts +96 -0
  208. package/src/lib/fatal-error.test.ts +97 -0
  209. package/src/lib/fatal-error.ts +165 -0
  210. package/src/lib/folder-roles.test.ts +113 -0
  211. package/src/lib/folder-roles.ts +112 -0
  212. package/src/lib/format.test.ts +115 -0
  213. package/src/lib/format.ts +198 -0
  214. package/src/lib/i18n.ts +36 -0
  215. package/src/lib/intelligence-pref.test.ts +57 -0
  216. package/src/lib/intelligence-pref.ts +25 -0
  217. package/src/lib/keymap-dispatch.test.ts +178 -0
  218. package/src/lib/keymap-dispatch.ts +187 -0
  219. package/src/lib/keymap.test.ts +49 -0
  220. package/src/lib/keymap.ts +178 -0
  221. package/src/lib/mail-context.ts +64 -0
  222. package/src/lib/mail-route.test.ts +80 -0
  223. package/src/lib/mail-route.ts +44 -0
  224. package/src/lib/message-body-source.test.ts +98 -0
  225. package/src/lib/message-body-source.ts +48 -0
  226. package/src/lib/move-targets.test.ts +195 -0
  227. package/src/lib/move-targets.ts +74 -0
  228. package/src/lib/onboarding-completion.test.ts +64 -0
  229. package/src/lib/onboarding-completion.ts +23 -0
  230. package/src/lib/organize/filter-status.test.ts +99 -0
  231. package/src/lib/organize/filter-status.ts +86 -0
  232. package/src/lib/organize/organize-copy.test.ts +93 -0
  233. package/src/lib/organize/organize-copy.ts +61 -0
  234. package/src/lib/organize/organize-model.test.ts +104 -0
  235. package/src/lib/organize/organize-model.ts +105 -0
  236. package/src/lib/organize/organize-poll.test.ts +37 -0
  237. package/src/lib/organize/organize-poll.ts +28 -0
  238. package/src/lib/outbox-status.test.ts +64 -0
  239. package/src/lib/outbox-status.ts +75 -0
  240. package/src/lib/plate-serializer.ts +164 -0
  241. package/src/lib/provider-presets.test.ts +96 -0
  242. package/src/lib/provider-presets.ts +81 -0
  243. package/src/lib/query-error-handler.test.ts +144 -0
  244. package/src/lib/query-error-handler.ts +34 -0
  245. package/src/lib/query-escalation.integration.test.ts +138 -0
  246. package/src/lib/recent-searches.test.ts +53 -0
  247. package/src/lib/recent-searches.ts +34 -0
  248. package/src/lib/rescue-candidates.test.ts +94 -0
  249. package/src/lib/rescue-candidates.ts +35 -0
  250. package/src/lib/rescue-telemetry.test.ts +75 -0
  251. package/src/lib/rescue-telemetry.ts +55 -0
  252. package/src/lib/rum-adapter.test.ts +299 -0
  253. package/src/lib/rum-adapter.ts +107 -0
  254. package/src/lib/saved-searches.test.ts +80 -0
  255. package/src/lib/saved-searches.ts +52 -0
  256. package/src/lib/search-pending.test.ts +61 -0
  257. package/src/lib/search-pending.ts +24 -0
  258. package/src/lib/search-query.test.ts +35 -0
  259. package/src/lib/search-query.ts +11 -0
  260. package/src/lib/search-result.test.ts +160 -0
  261. package/src/lib/search-result.ts +102 -0
  262. package/src/lib/search-schemas.ts +34 -0
  263. package/src/lib/search-token-index.test.ts +112 -0
  264. package/src/lib/search-token-index.ts +56 -0
  265. package/src/lib/search-tokens.test.ts +218 -0
  266. package/src/lib/search-tokens.ts +133 -0
  267. package/src/lib/telemetry-context.tsx +9 -0
  268. package/src/lib/telemetry.ts +17 -0
  269. package/src/lib/theme-preference.ts +46 -0
  270. package/src/lib/theme.ts +33 -0
  271. package/src/lib/utils.ts +4 -0
  272. package/src/main.tsx +10 -0
  273. package/src/routeTree.gen.ts +392 -0
  274. package/src/router.test.ts +81 -0
  275. package/src/router.tsx +31 -0
  276. package/src/routes/__root.tsx +69 -0
  277. package/src/routes/index.tsx +7 -0
  278. package/src/routes/mail/$mailboxId.tsx +27 -0
  279. package/src/routes/mail/flagged.tsx +37 -0
  280. package/src/routes/mail/index.tsx +41 -0
  281. package/src/routes/mail/outbox.tsx +13 -0
  282. package/src/routes/mail.tsx +413 -0
  283. package/src/routes/onboarding.tsx +35 -0
  284. package/src/routes/settings/-accounts.test.ts +65 -0
  285. package/src/routes/settings/accounts.tsx +542 -0
  286. package/src/routes/settings/advanced.tsx +55 -0
  287. package/src/routes/settings/appearance.tsx +140 -0
  288. package/src/routes/settings/filters.tsx +141 -0
  289. package/src/routes/settings/folders.tsx +216 -0
  290. package/src/routes/settings/index.tsx +5 -0
  291. package/src/routes/settings/senders.tsx +466 -0
  292. package/src/routes/settings/suggested-vips.tsx +10 -0
  293. package/src/routes/settings.tsx +66 -0
  294. package/src/runtime-config.ts +75 -0
  295. package/src/shell/index.tsx +98 -0
  296. package/src/types/index.ts +73 -0
  297. package/src/vite-env.d.ts +4 -0
  298. package/vite.base.ts +43 -0
@@ -0,0 +1,368 @@
1
+ import {
2
+ messageBulkOperationsUpdateFlagsMutation,
3
+ threadDetailOperationsListThreadMessagesQueryKey,
4
+ threadOperationsListThreadsQueryKey,
5
+ threadOperationsSearchThreadsQueryKey,
6
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
7
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
8
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
9
+ import { useNavigate, useSearch } from "@tanstack/react-router";
10
+ import {
11
+ BadgeCheck,
12
+ Check,
13
+ Code,
14
+ MailOpen,
15
+ MoreVertical,
16
+ Trash2,
17
+ } from "lucide-react";
18
+ import { useCallback } from "react";
19
+ import {
20
+ DropdownMenu,
21
+ DropdownMenuItem,
22
+ DropdownMenuSeparator,
23
+ } from "@/components/ui/DropdownMenu";
24
+ import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
25
+ import { ErrorState } from "@/components/ui/ErrorState";
26
+ import { formatErrorDetail } from "@/components/ui/error-banners";
27
+ import { useDeleteMessages } from "@/hooks/useDeleteMessages";
28
+ import { useMoveMessages } from "@/hooks/useMoveMessages";
29
+ import { useToggleTrusted } from "@/hooks/useToggleTrusted";
30
+ import { MoveToTrigger } from "./MoveToTrigger";
31
+
32
+ interface ThreadMessagesData {
33
+ items: RemitImapThreadMessageResponse[];
34
+ [key: string]: unknown;
35
+ }
36
+
37
+ interface ThreadsListPage {
38
+ items: RemitImapThreadMessageResponse[];
39
+ [key: string]: unknown;
40
+ }
41
+
42
+ interface ThreadsListData {
43
+ pages: ThreadsListPage[];
44
+ pageParams: Array<string | undefined>;
45
+ }
46
+
47
+ interface SnapshotEntry<T> {
48
+ queryKey: readonly unknown[];
49
+ data: T;
50
+ }
51
+
52
+ interface MarkUnreadContext {
53
+ threadMessagesPrefix: readonly unknown[];
54
+ threadsListPrefix: readonly unknown[];
55
+ threadsSearchPrefix: readonly unknown[];
56
+ previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
57
+ previousThreadsList: SnapshotEntry<ThreadsListData>[];
58
+ }
59
+
60
+ interface MessageActionMenuProps {
61
+ messageId: string;
62
+ threadId: string;
63
+ mailboxId: string;
64
+ isRead: boolean;
65
+ /**
66
+ * Account that owns this message's mailbox. When provided the
67
+ * Move-to-folder trigger is rendered next to the overflow; when
68
+ * omitted (e.g. the surrounding view hasn't resolved the account
69
+ * yet) the trigger is hidden so we never present a picker scoped to
70
+ * the wrong account.
71
+ */
72
+ accountId?: string;
73
+ /**
74
+ * The From-address `addressId` for the message. When omitted (e.g. the
75
+ * envelope has no parseable From) the "Trusted sender" toggle is
76
+ * disabled with an explanatory tooltip.
77
+ */
78
+ fromAddressId?: string;
79
+ /**
80
+ * Whether the From-address is currently flagged as trusted.
81
+ */
82
+ isTrusted?: boolean;
83
+ /**
84
+ * Whether the body area is currently showing the raw RFC822/MIME source
85
+ * instead of the rendered body. Controls the label/icon of the toggle.
86
+ */
87
+ showRaw?: boolean;
88
+ /**
89
+ * Toggle between the rendered body and the raw source. Owned by the
90
+ * parent card so the body area and this menu stay in sync.
91
+ */
92
+ onToggleRaw?: () => void;
93
+ }
94
+
95
+ export const MessageActionMenu = ({
96
+ messageId,
97
+ threadId,
98
+ mailboxId,
99
+ isRead,
100
+ accountId,
101
+ fromAddressId,
102
+ isTrusted = false,
103
+ showRaw = false,
104
+ onToggleRaw,
105
+ }: MessageActionMenuProps) => {
106
+ const queryClient = useQueryClient();
107
+ const navigate = useNavigate();
108
+ const { pushError } = useErrorBanners();
109
+ const { selectedMessageId } = useSearch({ strict: false }) as {
110
+ selectedMessageId?: string;
111
+ };
112
+
113
+ const { mutate: updateFlags, isPending: isUpdatingFlags } = useMutation({
114
+ ...messageBulkOperationsUpdateFlagsMutation(),
115
+ onMutate: async (variables): Promise<MarkUnreadContext> => {
116
+ const isReadNext = variables.body.isRead ?? true;
117
+ const targetIds = new Set(variables.body.messageIds ?? []);
118
+
119
+ const threadMessagesPrefix =
120
+ threadDetailOperationsListThreadMessagesQueryKey({
121
+ path: { threadId },
122
+ });
123
+ const threadsListPrefix = threadOperationsListThreadsQueryKey({
124
+ path: { mailboxId },
125
+ });
126
+ const threadsSearchPrefix = threadOperationsSearchThreadsQueryKey({
127
+ path: { mailboxId },
128
+ });
129
+
130
+ await Promise.all([
131
+ queryClient.cancelQueries({ queryKey: threadMessagesPrefix }),
132
+ queryClient.cancelQueries({ queryKey: threadsListPrefix }),
133
+ queryClient.cancelQueries({ queryKey: threadsSearchPrefix }),
134
+ ]);
135
+
136
+ const previousThreadMessages = queryClient
137
+ .getQueriesData<ThreadMessagesData>({ queryKey: threadMessagesPrefix })
138
+ .filter(
139
+ (entry): entry is [readonly unknown[], ThreadMessagesData] =>
140
+ entry[1] !== undefined,
141
+ )
142
+ .map(([queryKey, data]) => ({ queryKey, data }));
143
+
144
+ const previousThreadsList = queryClient
145
+ .getQueriesData<ThreadsListData>({ queryKey: threadsListPrefix })
146
+ .concat(
147
+ queryClient.getQueriesData<ThreadsListData>({
148
+ queryKey: threadsSearchPrefix,
149
+ }),
150
+ )
151
+ .filter(
152
+ (entry): entry is [readonly unknown[], ThreadsListData] =>
153
+ entry[1] !== undefined,
154
+ )
155
+ .map(([queryKey, data]) => ({ queryKey, data }));
156
+
157
+ queryClient.setQueriesData<ThreadMessagesData>(
158
+ { queryKey: threadMessagesPrefix },
159
+ (old) => {
160
+ if (!old) return old;
161
+ return {
162
+ ...old,
163
+ items: old.items.map((item) =>
164
+ targetIds.has(item.messageId)
165
+ ? { ...item, isRead: isReadNext }
166
+ : item,
167
+ ),
168
+ };
169
+ },
170
+ );
171
+
172
+ const patchListData = (old: ThreadsListData | undefined) => {
173
+ if (!old) return old;
174
+ return {
175
+ ...old,
176
+ pages: old.pages.map((page) => ({
177
+ ...page,
178
+ items: page.items.map((item) =>
179
+ targetIds.has(item.messageId)
180
+ ? { ...item, isRead: isReadNext }
181
+ : item,
182
+ ),
183
+ })),
184
+ };
185
+ };
186
+
187
+ queryClient.setQueriesData<ThreadsListData>(
188
+ { queryKey: threadsListPrefix },
189
+ patchListData,
190
+ );
191
+ queryClient.setQueriesData<ThreadsListData>(
192
+ { queryKey: threadsSearchPrefix },
193
+ patchListData,
194
+ );
195
+
196
+ return {
197
+ threadMessagesPrefix,
198
+ threadsListPrefix,
199
+ threadsSearchPrefix,
200
+ previousThreadMessages,
201
+ previousThreadsList,
202
+ };
203
+ },
204
+ onError: (err, variables, context) => {
205
+ if (context) {
206
+ for (const entry of context.previousThreadMessages) {
207
+ queryClient.setQueryData(entry.queryKey, entry.data);
208
+ }
209
+ for (const entry of context.previousThreadsList) {
210
+ queryClient.setQueryData(entry.queryKey, entry.data);
211
+ }
212
+ }
213
+ const isReadNext = variables.body.isRead ?? true;
214
+ pushError({
215
+ title: isReadNext ? "Couldn't mark as read" : "Couldn't mark as unread",
216
+ detail: formatErrorDetail(err),
217
+ });
218
+ },
219
+ onSettled: (_data, _err, _vars, context) => {
220
+ if (!context) return;
221
+ queryClient.invalidateQueries({
222
+ queryKey: context.threadMessagesPrefix,
223
+ });
224
+ queryClient.invalidateQueries({ queryKey: context.threadsListPrefix });
225
+ queryClient.invalidateQueries({
226
+ queryKey: context.threadsSearchPrefix,
227
+ });
228
+ },
229
+ });
230
+
231
+ // Deselect the thread before the server response arrives if the message
232
+ // being deleted is the one currently routed to. Mirrors the bulk-delete
233
+ // flow on the mailbox route — without this, the URL keeps a stale
234
+ // `selectedMessageId` after the row vanishes from the list.
235
+ const handleAfterOptimisticRemove = useCallback(
236
+ (removedIds: string[]) => {
237
+ if (!selectedMessageId) return;
238
+ if (!removedIds.includes(selectedMessageId)) return;
239
+ navigate({
240
+ to: "/mail/$mailboxId",
241
+ params: { mailboxId },
242
+ search: (prev: Record<string, unknown>) => ({
243
+ ...prev,
244
+ selectedMessageId: undefined,
245
+ }),
246
+ });
247
+ },
248
+ [selectedMessageId, mailboxId, navigate],
249
+ );
250
+
251
+ const { deleteMessages, isPending: isDeleting } = useDeleteMessages({
252
+ mailboxId,
253
+ threadId,
254
+ onAfterOptimisticRemove: handleAfterOptimisticRemove,
255
+ });
256
+
257
+ const { moveMessages, isPending: isMoving } = useMoveMessages({
258
+ mailboxId,
259
+ threadId,
260
+ accountId,
261
+ onAfterOptimisticRemove: handleAfterOptimisticRemove,
262
+ });
263
+
264
+ const {
265
+ toggleTrusted,
266
+ isPending: isUpdatingTrusted,
267
+ error: trustedError,
268
+ reset: resetTrustedError,
269
+ } = useToggleTrusted({ messageId });
270
+
271
+ const handleMarkAsUnread = () => {
272
+ updateFlags({
273
+ body: {
274
+ messageIds: [messageId],
275
+ isRead: false,
276
+ },
277
+ });
278
+ };
279
+
280
+ const handleDelete = () => {
281
+ deleteMessages([messageId]);
282
+ };
283
+
284
+ const handleToggleTrusted = () => {
285
+ if (!fromAddressId) return;
286
+ toggleTrusted(fromAddressId, isTrusted);
287
+ };
288
+
289
+ const isDisabled =
290
+ isUpdatingFlags || isDeleting || isMoving || isUpdatingTrusted;
291
+ const trustedItemDisabled = isDisabled || !fromAddressId;
292
+
293
+ const handleMove = useCallback(
294
+ (destinationMailboxId: string) => {
295
+ moveMessages([messageId], destinationMailboxId);
296
+ },
297
+ [moveMessages, messageId],
298
+ );
299
+ const trustedItemTitle = !fromAddressId
300
+ ? "Sender has no parseable address"
301
+ : undefined;
302
+
303
+ return (
304
+ <div className="flex flex-col items-end gap-1">
305
+ <div className="flex items-center gap-1">
306
+ {accountId && (
307
+ <MoveToTrigger
308
+ accountId={accountId}
309
+ currentMailboxId={mailboxId}
310
+ onMove={handleMove}
311
+ disabled={isDisabled}
312
+ label="Move this message"
313
+ />
314
+ )}
315
+ <DropdownMenu trigger={<MoreVertical className="size-4" />}>
316
+ <DropdownMenuItem
317
+ onClick={handleToggleTrusted}
318
+ disabled={trustedItemDisabled}
319
+ >
320
+ {isTrusted ? (
321
+ <BadgeCheck className="size-4 text-positive" />
322
+ ) : (
323
+ <Check className="size-4 opacity-0" />
324
+ )}
325
+ <span title={trustedItemTitle}>Trusted sender</span>
326
+ </DropdownMenuItem>
327
+ {isRead && (
328
+ <DropdownMenuItem
329
+ onClick={handleMarkAsUnread}
330
+ disabled={isDisabled}
331
+ >
332
+ <MailOpen className="size-4" />
333
+ Mark as unread
334
+ </DropdownMenuItem>
335
+ )}
336
+ {onToggleRaw && (
337
+ <DropdownMenuItem onClick={onToggleRaw}>
338
+ <Code className="size-4" />
339
+ {showRaw ? "Show formatted" : "Show raw email"}
340
+ </DropdownMenuItem>
341
+ )}
342
+ <DropdownMenuSeparator />
343
+ <DropdownMenuItem
344
+ onClick={handleDelete}
345
+ disabled={isDisabled}
346
+ destructive
347
+ >
348
+ <Trash2 className="size-4" />
349
+ Delete
350
+ </DropdownMenuItem>
351
+ </DropdownMenu>
352
+ </div>
353
+ {trustedError && (
354
+ <div className="w-64">
355
+ <ErrorState
356
+ variant="inline"
357
+ title="Couldn't update trust"
358
+ error={trustedError}
359
+ onRetry={() => {
360
+ resetTrustedError();
361
+ handleToggleTrusted();
362
+ }}
363
+ />
364
+ </div>
365
+ )}
366
+ </div>
367
+ );
368
+ };
@@ -0,0 +1,207 @@
1
+ import {
2
+ buildCidResolver,
3
+ type CidResolver,
4
+ type EmailRenderCategory,
5
+ MessageBodyView,
6
+ } from "@remit/ui";
7
+ import { useMemo, useState } from "react";
8
+ import { useIsDark } from "@/hooks/useIsDark";
9
+ import { useMessageBodyContent } from "@/hooks/useMessageBodyContent";
10
+ import { useToggleTrusted } from "@/hooks/useToggleTrusted";
11
+ import { cn } from "@/lib/utils";
12
+ import { BlockedImagesNotice } from "./BlockedImagesNotice";
13
+ import { MessageBodyErrorBanner } from "./MessageBodyErrorBanner";
14
+
15
+ /**
16
+ * Subset of the OpenAPI `BodyPartResponse` consumed by this component for
17
+ * inline-image / attachment URL resolution and the body fetcher. Pinning a
18
+ * structural subset avoids a hard import on the generated types so this file
19
+ * compiles before `make` runs in fresh checkouts.
20
+ */
21
+ export interface MessageBodyPart {
22
+ bodyPartId: string;
23
+ mediaType: string;
24
+ mediaSubtype: string;
25
+ contentId?: string;
26
+ contentUrl: string;
27
+ disposition?: string;
28
+ dispositionFilename?: string;
29
+ isMultipart: boolean;
30
+ }
31
+
32
+ interface MessageBodyProps {
33
+ /**
34
+ * Body parts from `describeMessage`. The component picks the first
35
+ * `text/html` part (fallback `text/plain`) and fetches its `contentUrl`
36
+ * from CloudFront. `cid:CONTENT_ID` references in HTML resolve to other
37
+ * parts' `contentUrl` via the same list (#224 PR 3).
38
+ */
39
+ bodyParts?: readonly MessageBodyPart[];
40
+ /**
41
+ * Local-render fallback for callers that don't have BodyParts (Outbox
42
+ * drafts, Compose preview). When `bodyParts` is provided, these are
43
+ * ignored — the component fetches via CloudFront instead.
44
+ */
45
+ html?: string;
46
+ text?: string;
47
+ /**
48
+ * Whether the From-address has the `trusted` flag set. Trusted senders
49
+ * auto-load images. The "Load images" bar is suppressed entirely for
50
+ * trusted senders.
51
+ */
52
+ isTrusted?: boolean;
53
+ /**
54
+ * The current message id. Required for the body-content query cache key
55
+ * and by `useToggleTrusted` for the optimistic cache patch.
56
+ */
57
+ messageId?: string;
58
+ /**
59
+ * The From-address `addressId`. When omitted (e.g. the envelope has no
60
+ * parseable From) the "Always trust" button is hidden.
61
+ */
62
+ fromAddressId?: string;
63
+ /**
64
+ * Message category (personal/newsletter/marketing/…). Together with
65
+ * `hasAuthorBackground` this determines whether to apply the framed
66
+ * newsletter treatment or the plain-email normalization CSS.
67
+ */
68
+ category?: EmailRenderCategory;
69
+ /**
70
+ * Extra classes for the `.message-body` wrapper. The single-message
71
+ * reading view (`MessageDetail`) passes `px-4` here so the body shares
72
+ * the header's horizontal inset (#729). `MessageCard` provides its own
73
+ * `px-5` inset via the surrounding card and leaves this unset.
74
+ */
75
+ className?: string;
76
+ }
77
+
78
+ const LoadingSkeleton = () => (
79
+ // biome-ignore lint/a11y/useSemanticElements: <div> with role="status" preserves block layout; <output> is inline
80
+ <div
81
+ className="animate-pulse space-y-2"
82
+ role="status"
83
+ aria-label="Loading message body"
84
+ >
85
+ <div className="h-4 bg-surface-sunken rounded w-full" />
86
+ <div className="h-4 bg-surface-sunken rounded w-11/12" />
87
+ <div className="h-4 bg-surface-sunken rounded w-3/4" />
88
+ <div className="h-4 bg-surface-sunken rounded w-5/6" />
89
+ </div>
90
+ );
91
+
92
+ const EmptyBody = () => (
93
+ <p className="text-fg-muted text-sm italic">
94
+ This message has no body content.
95
+ </p>
96
+ );
97
+
98
+ export const MessageBody = ({
99
+ bodyParts,
100
+ html,
101
+ text,
102
+ isTrusted = false,
103
+ messageId,
104
+ fromAddressId,
105
+ category,
106
+ className,
107
+ }: MessageBodyProps) => {
108
+ const [allowImagesOnce, setAllowImagesOnce] = useState(false);
109
+ const allowImages = isTrusted || allowImagesOnce;
110
+ const isDark = useIsDark();
111
+ // `useToggleTrusted` surfaces its own failure (banner + rollback) and a fatal
112
+ // 5xx escalates globally — no consumer-side error effect needed here.
113
+ const { toggleTrusted, isPending: isTrustPending } = useToggleTrusted({
114
+ messageId: messageId ?? "",
115
+ });
116
+
117
+ const hasParts = !!bodyParts && bodyParts.length > 0;
118
+ const {
119
+ picked,
120
+ data: fetched,
121
+ isLoading: isBodyLoading,
122
+ isError: isBodyError,
123
+ error: bodyError,
124
+ refetch: refetchBody,
125
+ } = useMessageBodyContent({
126
+ messageId,
127
+ bodyParts,
128
+ enabled: hasParts,
129
+ });
130
+
131
+ const resolveCid: CidResolver = useMemo(
132
+ () => buildCidResolver(bodyParts ?? []),
133
+ [bodyParts],
134
+ );
135
+
136
+ const renderedHtml =
137
+ hasParts && fetched?.kind === "html" ? fetched.body : html;
138
+ const renderedText =
139
+ hasParts && fetched?.kind === "text" ? fetched.body : text;
140
+
141
+ if (hasParts) {
142
+ if (isBodyLoading) {
143
+ return (
144
+ <div className={cn("message-body", className)}>
145
+ <LoadingSkeleton />
146
+ </div>
147
+ );
148
+ }
149
+ if (isBodyError) {
150
+ return (
151
+ <div className={cn("message-body", className)}>
152
+ <MessageBodyErrorBanner
153
+ error={bodyError}
154
+ onRetry={() => refetchBody()}
155
+ />
156
+ </div>
157
+ );
158
+ }
159
+ if (!picked) {
160
+ return (
161
+ <div className={cn("message-body", className)}>
162
+ <EmptyBody />
163
+ </div>
164
+ );
165
+ }
166
+ } else if (!html && !text) {
167
+ return (
168
+ <div className={cn("message-body", className)}>
169
+ <EmptyBody />
170
+ </div>
171
+ );
172
+ }
173
+
174
+ const canAlwaysTrust = Boolean(fromAddressId && messageId);
175
+ const handleAlwaysTrust = () => {
176
+ if (!fromAddressId || isTrustPending) return;
177
+ setAllowImagesOnce(true);
178
+ toggleTrusted(fromAddressId, false);
179
+ };
180
+
181
+ // The sanitize → classify → sandboxed-iframe rendering lives in the kit's
182
+ // `MessageBodyView` (the single source of truth, shared with Storybook's
183
+ // reading panes — #940). This component keeps the data orchestration:
184
+ // bodyParts → html/text, the allow-images-once trust state, and the
185
+ // privacy "images blocked" bar (it touches app trust state, so it stays
186
+ // here and is injected via `renderBlockedNotice`).
187
+ return (
188
+ <MessageBodyView
189
+ className={className}
190
+ html={renderedHtml}
191
+ text={renderedText}
192
+ isDark={isDark}
193
+ category={category}
194
+ allowImages={allowImages}
195
+ resolveCid={resolveCid}
196
+ renderBlockedNotice={(blockedImageCount) => (
197
+ <BlockedImagesNotice
198
+ blockedImageCount={blockedImageCount}
199
+ canAlwaysTrust={canAlwaysTrust}
200
+ isTrustPending={isTrustPending}
201
+ onLoadOnce={() => setAllowImagesOnce(true)}
202
+ onAlwaysTrust={handleAlwaysTrust}
203
+ />
204
+ )}
205
+ />
206
+ );
207
+ };
@@ -0,0 +1,85 @@
1
+ /**
2
+ * React-render smoke test for `MessageBodyErrorBanner`. Pure helpers are
3
+ * covered by `MessageBodyErrorBanner.test.ts`; this file proves the component
4
+ * itself renders without crashing in the two surfaces that matter:
5
+ *
6
+ * 1. local-dev (no Cognito, no `Authenticator.Provider` in scope) — the
7
+ * previous version of this component called `useAuthenticator` at the
8
+ * top of render, which throws `USE_AUTHENTICATOR_ERROR` outside the
9
+ * provider and would crash the whole MessageBody subtree on the first
10
+ * body-fetch failure. The fix gates the hook-using subcomponent behind
11
+ * `isCognitoConfigured()`. This test pins that behaviour.
12
+ * 2. body-missing variant — no sign-in CTA regardless of config, so it
13
+ * should also render fine without the provider.
14
+ *
15
+ * Uses `react-dom/server`'s `renderToString` so the assertion runs without
16
+ * pulling in jsdom/happy-dom (no new deps).
17
+ */
18
+
19
+ import assert from "node:assert/strict";
20
+ import { describe, it } from "node:test";
21
+ import { createElement } from "react";
22
+ import { renderToString } from "react-dom/server";
23
+ import { BodyFetchError } from "@/hooks/useMessageBodyContent";
24
+ import { MessageBodyErrorBanner } from "./MessageBodyErrorBanner";
25
+
26
+ const renderBanner = (error: unknown, onRetry?: () => void): string =>
27
+ renderToString(
28
+ createElement(MessageBodyErrorBanner, { error, onRetry }) as never,
29
+ );
30
+
31
+ /**
32
+ * The sign-in CTA is rendered as a `<button>` whose text is "Sign in again".
33
+ * The string "Sign in again" also appears as the prefix of the auth-variant
34
+ * detail copy, so the assertion must look for the BUTTON tag specifically,
35
+ * not the bare substring.
36
+ */
37
+ const hasSignInButton = (html: string): boolean =>
38
+ /<button\b[^>]*>Sign in again<\/button>/.test(html);
39
+
40
+ describe("MessageBodyErrorBanner renders without an Authenticator.Provider in scope (#401 review)", () => {
41
+ it("renders the body-missing variant without throwing — local-dev / production both reach this branch", () => {
42
+ const html = renderBanner(
43
+ new BodyFetchError("body-missing", "missing", 403),
44
+ );
45
+ assert.match(html, /Message body is missing in storage/);
46
+ assert.match(html, /data-reason="body-missing"/);
47
+ assert.equal(
48
+ hasSignInButton(html),
49
+ false,
50
+ "sign-in CTA must not appear for body-missing — only the auth variant",
51
+ );
52
+ });
53
+
54
+ it("renders the auth variant in local-dev WITHOUT mounting the sign-in CTA — `useAuthenticator` would throw outside `Authenticator.Provider` and crash the subtree", () => {
55
+ // Default test env: `globalThis.__REMIT_CONFIG__` is unset, so
56
+ // `isCognitoConfigured()` returns false — same shape as `AuthShell`
57
+ // running without the provider. If `MessageBodyErrorBanner` calls
58
+ // `useAuthenticator` unconditionally again, this `renderToString`
59
+ // call throws.
60
+ const html = renderBanner(
61
+ new BodyFetchError("auth", "Invalid id_token", 401),
62
+ );
63
+ assert.match(html, /Your session expired/);
64
+ assert.match(html, /data-reason="auth"/);
65
+ assert.equal(
66
+ hasSignInButton(html),
67
+ false,
68
+ "sign-in CTA must be suppressed when Cognito is not configured (no Authenticator.Provider in the tree) — pre-fix regression: the button rendered and `useAuthenticator` crashed at render",
69
+ );
70
+ });
71
+
72
+ it("renders the generic variant for unknown errors without crashing", () => {
73
+ const html = renderBanner(new Error("network down"));
74
+ assert.match(html, /Couldn.+t load message body/);
75
+ assert.match(html, /network down/);
76
+ });
77
+
78
+ it("renders a Retry button when onRetry is supplied (every variant)", () => {
79
+ const html = renderBanner(
80
+ new BodyFetchError("body-missing", "missing", 403),
81
+ () => undefined,
82
+ );
83
+ assert.match(html, /<button\b[^>]*>Retry<\/button>/);
84
+ });
85
+ });