@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,86 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { BodyFetchError } from "@/hooks/useMessageBodyContent";
4
+ import {
5
+ contentForReason,
6
+ extractFallbackDetail,
7
+ extractReason,
8
+ } from "./message-body-error-banner-content";
9
+
10
+ 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
+ it("renders a generic title for other 4xx/5xx and falls back to the error's own message in the detail", () => {
24
+ const content = contentForReason("generic", "Network connection lost");
25
+ assert.match(content.title, /couldn.?t load/i);
26
+ assert.equal(content.detail, "Network connection lost");
27
+ });
28
+
29
+ it("renders a generic-but-safe title for content-type-mismatch (the actual reason is too technical for the user, the security guard already refused to render)", () => {
30
+ const content = contentForReason("content-type-mismatch", "ignored");
31
+ assert.match(content.title, /couldn.?t load/i);
32
+ assert.match(content.detail, /content type|unexpected/i);
33
+ });
34
+
35
+ it("renders a generic title for the spa-shell-leak regression guard (#310) and flags it as an infrastructure bug", () => {
36
+ const content = contentForReason("spa-shell-leak", "ignored");
37
+ assert.match(content.title, /couldn.?t load/i);
38
+ assert.match(content.detail, /shell|infrastructure|report/i);
39
+ });
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
+ });
48
+
49
+ 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
+ it("falls back to generic for a plain Error (e.g. a network error before fetch returned)", () => {
61
+ assert.equal(extractReason(new Error("offline")), "generic");
62
+ });
63
+
64
+ it("falls back to generic for unknown shapes (null, undefined, strings)", () => {
65
+ assert.equal(extractReason(undefined), "generic");
66
+ assert.equal(extractReason(null), "generic");
67
+ assert.equal(extractReason("just a string"), "generic");
68
+ assert.equal(extractReason(42), "generic");
69
+ });
70
+ });
71
+
72
+ describe("extractFallbackDetail — used only when contentForReason picks the generic branch", () => {
73
+ it("returns an Error.message", () => {
74
+ assert.equal(extractFallbackDetail(new Error("boom")), "boom");
75
+ });
76
+
77
+ it("returns a string as-is", () => {
78
+ assert.equal(extractFallbackDetail("network error"), "network error");
79
+ });
80
+
81
+ it("returns a stable generic message for other shapes (never the raw object, never undefined)", () => {
82
+ const detail = extractFallbackDetail({ status: 502 });
83
+ assert.equal(typeof detail, "string");
84
+ assert.ok(detail.length > 0);
85
+ });
86
+ });
@@ -0,0 +1,90 @@
1
+ import { AlertCircle, X } from "lucide-react";
2
+ import { useState } from "react";
3
+ import { useAuthProvider } from "@/auth/provider";
4
+ import {
5
+ contentForReason,
6
+ extractFallbackDetail,
7
+ extractReason,
8
+ } from "./message-body-error-banner-content";
9
+
10
+ interface MessageBodyErrorBannerProps {
11
+ error: unknown;
12
+ onRetry?: () => void;
13
+ }
14
+
15
+ /**
16
+ * Inline alert banner for a body-fetch failure. Renders variant-specific copy
17
+ * for auth-expired vs. body-missing-in-storage 403s (issue #401), with a
18
+ * Retry button (always) and a Sign-in button (auth variant only, and only
19
+ * when a session is active to sign out of). Dismissible — once dismissed, the
20
+ * message body area falls back to an empty state until Retry is clicked.
21
+ *
22
+ * The sign-in affordance goes through the composed auth provider's `Account`
23
+ * render-prop: it renders only when there is a session to sign out of, so no
24
+ * identity SDK hook fires in a build (or a local-dev run) that has none.
25
+ *
26
+ * Uses the same destructive colour palette as `ErrorState(variant="inline")`
27
+ * so it looks at home in the message body slot.
28
+ */
29
+ export const MessageBodyErrorBanner = ({
30
+ error,
31
+ onRetry,
32
+ }: MessageBodyErrorBannerProps) => {
33
+ const reason = extractReason(error);
34
+ const fallback = extractFallbackDetail(error);
35
+ const { title, detail } = contentForReason(reason, fallback);
36
+ const [dismissed, setDismissed] = useState(false);
37
+ const { Account } = useAuthProvider();
38
+
39
+ if (dismissed) return null;
40
+
41
+ return (
42
+ <div
43
+ role="alert"
44
+ data-testid="message-body-error-banner"
45
+ data-reason={reason}
46
+ className="flex items-start gap-2 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm"
47
+ >
48
+ <AlertCircle
49
+ className="size-4 shrink-0 text-danger mt-0.5"
50
+ aria-hidden="true"
51
+ />
52
+ <div className="flex-1 min-w-0">
53
+ <p className="font-medium text-danger">{title}</p>
54
+ <p className="text-fg-muted mt-1 break-words">{detail}</p>
55
+ </div>
56
+ <div className="flex shrink-0 items-center gap-3">
57
+ {reason === "auth" && (
58
+ <Account>
59
+ {({ signOut }) => (
60
+ <button
61
+ type="button"
62
+ onClick={() => signOut()}
63
+ className="text-sm font-medium text-accent hover:underline"
64
+ >
65
+ Sign in again
66
+ </button>
67
+ )}
68
+ </Account>
69
+ )}
70
+ {onRetry && (
71
+ <button
72
+ type="button"
73
+ onClick={onRetry}
74
+ className="text-sm font-medium text-accent hover:underline"
75
+ >
76
+ Retry
77
+ </button>
78
+ )}
79
+ <button
80
+ type="button"
81
+ onClick={() => setDismissed(true)}
82
+ aria-label="Dismiss message body error"
83
+ className="rounded-md p-1 text-fg-muted hover:bg-surface-raised hover:text-fg transition-colors"
84
+ >
85
+ <X className="size-4" aria-hidden="true" />
86
+ </button>
87
+ </div>
88
+ </div>
89
+ );
90
+ };
@@ -0,0 +1,401 @@
1
+ import { messageOperationsDescribeMessageOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type {
3
+ RemitImapDescribeMessageResponse,
4
+ RemitImapThreadMessageResponse,
5
+ } from "@remit/api-http-client/types.gen.ts";
6
+ import type { ThreadMessageData } from "@remit/ui";
7
+ import {
8
+ AddressList,
9
+ CollapsedMessage,
10
+ ExpandedMessage,
11
+ } from "@remit/ui";
12
+ import { useQuery } from "@tanstack/react-query";
13
+ import { BadgeCheck, Paperclip, Star } from "lucide-react";
14
+ import { useState } from "react";
15
+ import { EmptyState } from "@/components/ui/EmptyState";
16
+ import { ErrorState } from "@/components/ui/ErrorState";
17
+ import { isMessageNotFoundError } from "@/components/ui/error-banners";
18
+ import { toDisplayCategory } from "@/lib/display-category";
19
+ import { formatDatePreset } from "@/lib/format";
20
+ import { cn } from "@/lib/utils";
21
+ import { AutoMovedIndicator } from "./AutoMovedIndicator";
22
+ import { MessageActionMenu } from "./MessageActionMenu";
23
+ import { MessageBody } from "./MessageBody";
24
+ import { MobileMessageBar } from "./MobileMessageBar";
25
+ import { RawMessageView } from "./RawMessageView";
26
+
27
+ const TrustedSenderBadge = () => (
28
+ <BadgeCheck
29
+ className="inline-block size-4 ml-1 -mt-0.5 text-positive align-middle"
30
+ aria-label="Trusted sender"
31
+ data-testid="trusted-sender-badge"
32
+ />
33
+ );
34
+
35
+ /**
36
+ * Builds the kit's `ThreadMessageData` view shape from an IMAP thread message.
37
+ * The kit reading rows own the row layout; MessageCard supplies the data and
38
+ * injects its interactivity through slots — so the row rhythm has one source of
39
+ * truth and can't drift from Storybook (#945).
40
+ */
41
+ const toThreadMessageData = (
42
+ threadMessage: RemitImapThreadMessageResponse,
43
+ dateLabel: string,
44
+ ): ThreadMessageData => ({
45
+ id: threadMessage.messageId,
46
+ fromName: threadMessage.fromName ?? threadMessage.fromEmail ?? "?",
47
+ fromEmail: threadMessage.fromEmail ?? "",
48
+ toLabel: "",
49
+ dateLabel,
50
+ snippet: threadMessage.snippet || "",
51
+ bodyHtml: "",
52
+ });
53
+
54
+ const StarButton = ({
55
+ isStarred,
56
+ onToggleStar,
57
+ isStarPending,
58
+ hoverReveal,
59
+ }: {
60
+ isStarred: boolean;
61
+ onToggleStar: () => void;
62
+ isStarPending?: boolean;
63
+ hoverReveal?: boolean;
64
+ }) => (
65
+ <button
66
+ type="button"
67
+ onClick={(e) => {
68
+ e.stopPropagation();
69
+ onToggleStar();
70
+ }}
71
+ disabled={isStarPending}
72
+ aria-label={isStarred ? "Remove star" : "Add star"}
73
+ className={cn(
74
+ "shrink-0 p-0.5 rounded transition-colors",
75
+ isStarred
76
+ ? "text-warning"
77
+ : cn(
78
+ "text-fg-subtle hover:text-warning",
79
+ hoverReveal &&
80
+ "opacity-0 group-hover:opacity-100 focus:opacity-100",
81
+ ),
82
+ isStarPending && "opacity-50",
83
+ )}
84
+ >
85
+ <Star className={cn("size-3.5", isStarred && "fill-current")} />
86
+ </button>
87
+ );
88
+
89
+ interface MessageCardProps {
90
+ threadMessage: RemitImapThreadMessageResponse;
91
+ isExpanded: boolean;
92
+ isFocused?: boolean;
93
+ onToggle: () => void;
94
+ onToggleStar: () => void;
95
+ isStarPending?: boolean;
96
+ /**
97
+ * Owning account for the thread's mailbox. Forwarded to
98
+ * `MessageActionMenu` so the per-message Move trigger can scope its
99
+ * folder picker to the right account. Optional because the resolver
100
+ * is async — when omitted the Move trigger is hidden until the lookup
101
+ * completes.
102
+ */
103
+ accountId?: string;
104
+ /**
105
+ * Single-pane mobile reading: the expanded card owns a per-message
106
+ * `MobileMessageBar` (reply / star / move / delete / overflow) instead of
107
+ * the desktop star indicator + kebab, so nothing is duplicated against the
108
+ * shell. Reply verbs are supplied by the conversation.
109
+ */
110
+ mobile?: boolean;
111
+ onReply?: () => void;
112
+ onReplyAll?: () => void;
113
+ onForward?: () => void;
114
+ }
115
+
116
+ const CollapsedCard = ({
117
+ threadMessage,
118
+ isFocused,
119
+ onToggle,
120
+ onToggleStar,
121
+ isStarPending,
122
+ }: {
123
+ threadMessage: RemitImapThreadMessageResponse;
124
+ isFocused?: boolean;
125
+ onToggle: () => void;
126
+ onToggleStar: () => void;
127
+ isStarPending?: boolean;
128
+ }) => {
129
+ const date = formatDatePreset(threadMessage.sentDate, "datetime");
130
+ const message = toThreadMessageData(threadMessage, date);
131
+
132
+ return (
133
+ <CollapsedMessage
134
+ message={message}
135
+ onClick={onToggle}
136
+ isFocused={isFocused}
137
+ isUnread={!threadMessage.isRead}
138
+ trailing={
139
+ <>
140
+ {threadMessage.hasAttachment && (
141
+ <Paperclip className="size-3 shrink-0 text-fg-subtle" />
142
+ )}
143
+ <StarButton
144
+ isStarred={threadMessage.hasStars}
145
+ onToggleStar={onToggleStar}
146
+ isStarPending={isStarPending}
147
+ hoverReveal
148
+ />
149
+ <span
150
+ data-testid="message-date"
151
+ className="shrink-0 text-2xs text-fg-subtle tabular-nums"
152
+ >
153
+ {date}
154
+ </span>
155
+ </>
156
+ }
157
+ />
158
+ );
159
+ };
160
+
161
+ const ExpandedCard = ({
162
+ threadMessage,
163
+ messageData,
164
+ isLoading,
165
+ isError,
166
+ error,
167
+ onRetry,
168
+ isFocused,
169
+ onToggle,
170
+ onToggleStar,
171
+ isStarPending,
172
+ accountId,
173
+ showRaw,
174
+ onToggleRaw,
175
+ mobile,
176
+ onReply,
177
+ onReplyAll,
178
+ onForward,
179
+ }: {
180
+ threadMessage: RemitImapThreadMessageResponse;
181
+ messageData?: RemitImapDescribeMessageResponse;
182
+ isLoading: boolean;
183
+ isError: boolean;
184
+ error: unknown;
185
+ onRetry: () => void;
186
+ isFocused?: boolean;
187
+ onToggle: () => void;
188
+ onToggleStar: () => void;
189
+ isStarPending?: boolean;
190
+ accountId?: string;
191
+ showRaw: boolean;
192
+ onToggleRaw: () => void;
193
+ mobile?: boolean;
194
+ onReply?: () => void;
195
+ onReplyAll?: () => void;
196
+ onForward?: () => void;
197
+ }) => {
198
+ const date = formatDatePreset(threadMessage.sentDate, "long");
199
+ const isUnread = !threadMessage.isRead;
200
+ const isTrusted =
201
+ messageData?.envelope.from[0]?.flags?.trusted?.value === true;
202
+ const fromAddressId = messageData?.envelope.from[0]?.addressId;
203
+ const message = toThreadMessageData(threadMessage, date);
204
+
205
+ const autoMovedNode = threadMessage.autoMoved ? (
206
+ <AutoMovedIndicator
207
+ accountId={accountId}
208
+ messageId={threadMessage.messageId}
209
+ threadId={threadMessage.threadId}
210
+ mailboxId={threadMessage.mailboxId}
211
+ autoMoved={threadMessage.autoMoved}
212
+ size="md"
213
+ />
214
+ ) : null;
215
+
216
+ return (
217
+ <ExpandedMessage
218
+ message={message}
219
+ isFocused={isFocused}
220
+ onHeaderClick={onToggle}
221
+ senderBadge={isTrusted ? <TrustedSenderBadge /> : undefined}
222
+ trailing={
223
+ <span data-testid="message-date" className="text-2xs text-fg-subtle">
224
+ {date}
225
+ </span>
226
+ }
227
+ to={
228
+ messageData ? (
229
+ <AddressList label="To" addresses={messageData.envelope.to} />
230
+ ) : null
231
+ }
232
+ indicators={
233
+ autoMovedNode || !mobile ? (
234
+ <div className="flex flex-wrap items-center gap-1.5 mt-0.5">
235
+ {autoMovedNode}
236
+ {!mobile && (
237
+ <>
238
+ <div className="flex items-center justify-end gap-1">
239
+ <StarButton
240
+ isStarred={threadMessage.hasStars}
241
+ onToggleStar={onToggleStar}
242
+ isStarPending={isStarPending}
243
+ />
244
+ {threadMessage.hasAttachment && (
245
+ <span className="text-fg-subtle p-0.5">
246
+ <Paperclip className="size-3.5" />
247
+ </span>
248
+ )}
249
+ </div>
250
+ {isUnread && (
251
+ // biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-label on decorative indicator provides useful context for assistive tech
252
+ <span
253
+ className="size-1.5 rounded-full bg-accent"
254
+ aria-label="Unread"
255
+ />
256
+ )}
257
+ </>
258
+ )}
259
+ </div>
260
+ ) : undefined
261
+ }
262
+ actionMenu={
263
+ mobile ? undefined : (
264
+ <MessageActionMenu
265
+ messageId={threadMessage.messageId}
266
+ threadId={threadMessage.threadId}
267
+ mailboxId={threadMessage.mailboxId}
268
+ isRead={threadMessage.isRead}
269
+ accountId={accountId}
270
+ fromAddressId={fromAddressId}
271
+ isTrusted={isTrusted}
272
+ showRaw={showRaw}
273
+ onToggleRaw={onToggleRaw}
274
+ />
275
+ )
276
+ }
277
+ actionBar={
278
+ mobile ? (
279
+ <MobileMessageBar
280
+ messageId={threadMessage.messageId}
281
+ threadId={threadMessage.threadId}
282
+ mailboxId={threadMessage.mailboxId}
283
+ accountId={accountId}
284
+ isRead={threadMessage.isRead}
285
+ isStarred={threadMessage.hasStars}
286
+ onToggleStar={onToggleStar}
287
+ fromAddressId={fromAddressId}
288
+ isTrusted={isTrusted}
289
+ showRaw={showRaw}
290
+ onToggleRaw={onToggleRaw}
291
+ onReply={onReply}
292
+ onReplyAll={onReplyAll}
293
+ onForward={onForward}
294
+ />
295
+ ) : undefined
296
+ }
297
+ body={
298
+ isLoading ? (
299
+ <div className="mt-3 animate-pulse space-y-2">
300
+ <div className="h-4 bg-surface-sunken rounded w-full" />
301
+ <div className="h-4 bg-surface-sunken rounded w-3/4" />
302
+ <div className="h-4 bg-surface-sunken rounded w-1/2" />
303
+ </div>
304
+ ) : isError && isMessageNotFoundError(error) ? (
305
+ <div className="mt-3">
306
+ <EmptyState message="This message has been deleted" />
307
+ </div>
308
+ ) : isError ? (
309
+ <div className="mt-3">
310
+ <ErrorState
311
+ variant="inline"
312
+ title="Couldn't load this message"
313
+ error={error}
314
+ onRetry={onRetry}
315
+ />
316
+ </div>
317
+ ) : showRaw ? (
318
+ <div className="mt-3">
319
+ <RawMessageView messageId={threadMessage.messageId} />
320
+ </div>
321
+ ) : (
322
+ <div className="mt-3">
323
+ <MessageBody
324
+ bodyParts={messageData?.bodyParts}
325
+ messageId={threadMessage.messageId}
326
+ fromAddressId={messageData?.envelope.from[0]?.addressId}
327
+ isTrusted={isTrusted}
328
+ category={toDisplayCategory(threadMessage.category)}
329
+ />
330
+ </div>
331
+ )
332
+ }
333
+ />
334
+ );
335
+ };
336
+
337
+ export const MessageCard = ({
338
+ threadMessage,
339
+ isExpanded,
340
+ isFocused,
341
+ onToggle,
342
+ onToggleStar,
343
+ isStarPending,
344
+ accountId,
345
+ mobile,
346
+ onReply,
347
+ onReplyAll,
348
+ onForward,
349
+ }: MessageCardProps) => {
350
+ const [showRaw, setShowRaw] = useState(false);
351
+ const {
352
+ data: messageData,
353
+ isLoading,
354
+ isError,
355
+ error,
356
+ refetch,
357
+ } = useQuery({
358
+ ...messageOperationsDescribeMessageOptions({
359
+ path: { messageId: threadMessage.messageId },
360
+ }),
361
+ enabled: isExpanded,
362
+ // A 404 (row deleted / mid-refresh) renders the inline "deleted" empty
363
+ // state below — opt it out of the global fatal overlay. A 5xx still
364
+ // escalates globally (meta.softError is ignored for 5xx — #1059).
365
+ meta: { softError: true },
366
+ });
367
+
368
+ if (!isExpanded) {
369
+ return (
370
+ <CollapsedCard
371
+ threadMessage={threadMessage}
372
+ isFocused={isFocused}
373
+ onToggle={onToggle}
374
+ onToggleStar={onToggleStar}
375
+ isStarPending={isStarPending}
376
+ />
377
+ );
378
+ }
379
+
380
+ return (
381
+ <ExpandedCard
382
+ threadMessage={threadMessage}
383
+ messageData={messageData}
384
+ isLoading={isLoading}
385
+ isError={isError}
386
+ error={error}
387
+ onRetry={() => refetch()}
388
+ isFocused={isFocused}
389
+ onToggle={onToggle}
390
+ onToggleStar={onToggleStar}
391
+ isStarPending={isStarPending}
392
+ accountId={accountId}
393
+ showRaw={showRaw}
394
+ onToggleRaw={() => setShowRaw((prev) => !prev)}
395
+ mobile={mobile}
396
+ onReply={onReply}
397
+ onReplyAll={onReplyAll}
398
+ onForward={onForward}
399
+ />
400
+ );
401
+ };