@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,462 @@
1
+ import {
2
+ configOperationsGetConfigOptions,
3
+ messageOperationsDescribeMessageOptions,
4
+ threadDetailOperationsListThreadMessagesOptions,
5
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
6
+ import type { RemitImapMessageAuthenticity } from "@remit/api-http-client/types.gen.ts";
7
+ import { MobileReadingPane } from "@remit/ui";
8
+ import { useQuery } from "@tanstack/react-query";
9
+ import { ShieldAlert } from "lucide-react";
10
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
11
+ import type { ComposeMode } from "@/components/compose/ComposeProvider";
12
+ import { InlineCompose } from "@/components/compose/InlineCompose";
13
+ import { EmptyState } from "@/components/ui/EmptyState";
14
+ import { ErrorState } from "@/components/ui/ErrorState";
15
+ import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
16
+ import { useMailboxAccount } from "@/hooks/useMailboxAccount";
17
+ import { useMarkAsRead } from "@/hooks/useMarkAsRead";
18
+ import { useIsDesktop } from "@/hooks/useMediaQuery";
19
+ import { useSwipeNavigation } from "@/hooks/useSwipeNavigation";
20
+ import { useToggleStar } from "@/hooks/useToggleStar";
21
+ import { MessageCard } from "./MessageCard";
22
+
23
+ interface ConversationViewProps {
24
+ threadId: string;
25
+ mailboxId: string;
26
+ subject?: string;
27
+ /**
28
+ * The messageId of the row the user clicked in the thread list. When
29
+ * provided, this message is expanded on open (in addition to the newest
30
+ * message) so that viewing an older unread message marks it as read.
31
+ */
32
+ selectedMessageId?: string;
33
+ /**
34
+ * Authenticity signal from the thread row (DKIM mismatch). When present
35
+ * and `dkimMismatch` is true, a danger banner renders above the message
36
+ * body with a "Why?" link that opens the intelligence sidebar.
37
+ */
38
+ authenticity?: RemitImapMessageAuthenticity;
39
+ /**
40
+ * Callback for the "Why?" link in the authenticity banner — opens /
41
+ * focuses the intelligence sidebar.
42
+ */
43
+ onOpenIntelligence?: () => void;
44
+ /**
45
+ * Mobile callers pass `onBack` to render a sticky Back button at the
46
+ * bottom of the conversation. Desktop callers omit it — the message
47
+ * list is always visible in the resizable side pane.
48
+ */
49
+ onBack?: () => void;
50
+ /**
51
+ * When set, immediately opens the inline compose in this mode. The
52
+ * parent (e.g. the reading-pane toolbar) sets this to wire the
53
+ * top-bar reply/reply-all/forward buttons into the inline compose
54
+ * that lives inside the conversation. Reset by calling
55
+ * `onComposeClose` after the compose opens.
56
+ */
57
+ composeRequest?: ComposeMode | null;
58
+ /**
59
+ * Called once the conversation has acted on `composeRequest` (or
60
+ * whenever the inline compose is dismissed). The caller should reset
61
+ * `composeRequest` to `null`.
62
+ */
63
+ onComposeClose?: () => void;
64
+ /**
65
+ * Mobile only: swipe-left handler to open the next message in the list the
66
+ * thread was opened from. Omitted at the end of the list so the gesture
67
+ * no-ops gracefully.
68
+ */
69
+ onSwipeNext?: () => void;
70
+ /**
71
+ * Mobile only: swipe-right handler to open the previous message in the list.
72
+ * Omitted at the start of the list.
73
+ */
74
+ onSwipePrevious?: () => void;
75
+ /** Whether the intelligence drawer is currently open (drives the ⓘ button pressed state). */
76
+ mobileIntelligenceOpen?: boolean;
77
+ }
78
+
79
+ const LoadingSkeleton = () => (
80
+ <div className="animate-pulse p-4">
81
+ <div className="h-6 bg-surface-sunken rounded w-3/4 mb-5" />
82
+ <div className="space-y-4">
83
+ {Array.from({ length: 2 }).map((_, i) => (
84
+ // biome-ignore lint/suspicious/noArrayIndexKey: list is static, no stable id
85
+ <div key={i} className="flex gap-3 py-3">
86
+ <div className="size-10 bg-surface-sunken rounded-full shrink-0" />
87
+ <div className="flex-1">
88
+ <div className="h-4 bg-surface-sunken rounded w-32 mb-2" />
89
+ <div className="h-3 bg-surface-sunken rounded w-48" />
90
+ </div>
91
+ </div>
92
+ ))}
93
+ </div>
94
+ </div>
95
+ );
96
+
97
+ /**
98
+ * Danger banner rendered above the thread body when DKIM mismatch is detected.
99
+ * Design spec (03-reading-and-intelligence.md): "a danger banner renders above
100
+ * the body: 'This message claims to be a company but was sent from a personal
101
+ * mailbox.' with a 'Why?' link that opens/highlights this section."
102
+ */
103
+ function AuthenticityBanner({
104
+ authenticity,
105
+ onOpenIntelligence,
106
+ }: {
107
+ authenticity: RemitImapMessageAuthenticity;
108
+ onOpenIntelligence?: () => void;
109
+ }) {
110
+ if (!authenticity.dkimMismatch) return null;
111
+
112
+ return (
113
+ <div className="flex items-start gap-2 rounded-none border-b border-danger/20 bg-danger-soft px-5 py-2.5 text-sm">
114
+ <ShieldAlert className="mt-0.5 size-4 shrink-0 text-danger" />
115
+ <p className="flex-1 leading-snug text-fg">
116
+ This message claims to be from{" "}
117
+ <span className="font-semibold">{authenticity.fromDomain}</span> but was
118
+ sent
119
+ {authenticity.dkimDomain
120
+ ? ` via ${authenticity.dkimDomain}`
121
+ : " from a different domain"}
122
+ .
123
+ </p>
124
+ {onOpenIntelligence && (
125
+ <button
126
+ type="button"
127
+ onClick={onOpenIntelligence}
128
+ className="shrink-0 text-2xs font-medium text-danger hover:underline"
129
+ >
130
+ Why?
131
+ </button>
132
+ )}
133
+ </div>
134
+ );
135
+ }
136
+
137
+ export const ConversationView = ({
138
+ threadId,
139
+ mailboxId,
140
+ subject,
141
+ selectedMessageId,
142
+ authenticity,
143
+ onOpenIntelligence,
144
+ onBack,
145
+ composeRequest,
146
+ onComposeClose,
147
+ onSwipeNext,
148
+ onSwipePrevious,
149
+ mobileIntelligenceOpen,
150
+ }: ConversationViewProps) => {
151
+ const isDesktop = useIsDesktop();
152
+ const { handlers: swipeHandlers } = useSwipeNavigation({
153
+ onSwipeLeft: onSwipeNext,
154
+ onSwipeRight: onSwipePrevious,
155
+ });
156
+ const { accountId: mailboxAccountId } = useMailboxAccount(mailboxId);
157
+ const {
158
+ data: messagesResponse,
159
+ isLoading,
160
+ isError,
161
+ error,
162
+ refetch,
163
+ } = useQuery({
164
+ ...threadDetailOperationsListThreadMessagesOptions({
165
+ path: { threadId },
166
+ query: { order: "desc", mailboxId },
167
+ }),
168
+ });
169
+
170
+ const messages = useMemo(
171
+ () => messagesResponse?.items ?? [],
172
+ [messagesResponse?.items],
173
+ );
174
+ const messageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
175
+
176
+ // Track which messages are expanded
177
+ // By default, the first (newest) message is expanded
178
+ const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
179
+ const [currentThreadId, setCurrentThreadId] = useState<string | null>(null);
180
+ const [focusedIndex, setFocusedIndex] = useState(0);
181
+
182
+ // Reset and expand first message when thread changes or messages load.
183
+ // Also expand the selected message (the row the user clicked in the list)
184
+ // so that an older unread message is visible and gets marked as read.
185
+ useEffect(() => {
186
+ if (messages.length > 0 && threadId !== currentThreadId) {
187
+ setCurrentThreadId(threadId);
188
+ const initialExpanded = new Set([messages[0].threadMessageId]);
189
+ if (selectedMessageId) {
190
+ const selected = messages.find(
191
+ (m) => m.messageId === selectedMessageId,
192
+ );
193
+ if (selected) initialExpanded.add(selected.threadMessageId);
194
+ }
195
+ setExpandedIds(initialExpanded);
196
+ setFocusedIndex(0);
197
+ }
198
+ }, [threadId, messages, currentThreadId, selectedMessageId]);
199
+
200
+ const toggleExpanded = useCallback((threadMessageId: string) => {
201
+ setExpandedIds((prev) => {
202
+ const next = new Set(prev);
203
+ if (next.has(threadMessageId)) {
204
+ next.delete(threadMessageId);
205
+ } else {
206
+ next.add(threadMessageId);
207
+ }
208
+ return next;
209
+ });
210
+ }, []);
211
+
212
+ // Scroll focused message into view
213
+ const scrollToMessage = useCallback(
214
+ (index: number) => {
215
+ const message = messages[index];
216
+ if (!message) return;
217
+ const element = messageRefs.current.get(message.threadMessageId);
218
+ element?.scrollIntoView({ behavior: "smooth", block: "nearest" });
219
+ },
220
+ [messages],
221
+ );
222
+
223
+ // Keyboard navigation handlers
224
+ const focusNext = useCallback(() => {
225
+ if (messages.length === 0) return;
226
+ const nextIndex = Math.min(focusedIndex + 1, messages.length - 1);
227
+ setFocusedIndex(nextIndex);
228
+ scrollToMessage(nextIndex);
229
+ }, [messages.length, focusedIndex, scrollToMessage]);
230
+
231
+ const focusPrevious = useCallback(() => {
232
+ if (messages.length === 0) return;
233
+ const prevIndex = Math.max(focusedIndex - 1, 0);
234
+ setFocusedIndex(prevIndex);
235
+ scrollToMessage(prevIndex);
236
+ }, [messages.length, focusedIndex, scrollToMessage]);
237
+
238
+ const toggleFocusedMessage = useCallback(() => {
239
+ const message = messages[focusedIndex];
240
+ if (message) {
241
+ toggleExpanded(message.threadMessageId);
242
+ }
243
+ }, [messages, focusedIndex, toggleExpanded]);
244
+
245
+ const { data: config } = useQuery({
246
+ ...configOperationsGetConfigOptions(),
247
+ staleTime: Infinity,
248
+ });
249
+ const activeAccount = config?.accounts?.[0];
250
+ const smtpConfigured = !!activeAccount?.smtpHost;
251
+
252
+ // Mark messages as read immediately when expanded.
253
+ useMarkAsRead({
254
+ messages,
255
+ expandedIds,
256
+ threadId,
257
+ mailboxId,
258
+ accountId: activeAccount?.accountId,
259
+ });
260
+
261
+ // Star toggle functionality
262
+ const {
263
+ toggleStar,
264
+ isPending: isStarPending,
265
+ pendingMessageId,
266
+ } = useToggleStar({
267
+ threadId,
268
+ mailboxId,
269
+ });
270
+
271
+ // Compose state for inline reply/forward.
272
+ // Controlled via the `composeRequest` prop (toolbar wire-up) or
273
+ // locally via r/a/f keyboard shortcuts.
274
+ const [composeMode, setComposeMode] = useState<ComposeMode | null>(null);
275
+
276
+ // When the toolbar passes a composeRequest, open the inline compose.
277
+ useEffect(() => {
278
+ if (composeRequest && composeRequest !== "new") {
279
+ setComposeMode(composeRequest);
280
+ // Notify the parent that the request has been consumed.
281
+ onComposeClose?.();
282
+ }
283
+ }, [composeRequest, onComposeClose]);
284
+
285
+ const lastMessage = messages[0];
286
+ const { data: lastMessageData } = useQuery({
287
+ ...messageOperationsDescribeMessageOptions({
288
+ path: { messageId: lastMessage?.messageId ?? "" },
289
+ }),
290
+ enabled: !!lastMessage && composeMode !== null,
291
+ });
292
+
293
+ const handleReply = useCallback(() => {
294
+ setComposeMode("reply");
295
+ }, []);
296
+
297
+ const handleReplyAll = useCallback(() => {
298
+ setComposeMode("reply_all");
299
+ }, []);
300
+
301
+ const handleForward = useCallback(() => {
302
+ setComposeMode("forward");
303
+ }, []);
304
+
305
+ const handleCloseCompose = useCallback(() => {
306
+ setComposeMode(null);
307
+ onComposeClose?.();
308
+ }, [onComposeClose]);
309
+
310
+ // Register keyboard shortcuts
311
+ useKeyboardNavigation({
312
+ enabled: !isLoading && messages.length > 0 && composeMode === null,
313
+ bindings: [
314
+ { key: "j", handler: focusNext, preventDefault: true },
315
+ { key: "ArrowDown", handler: focusNext, preventDefault: true },
316
+ { key: "k", handler: focusPrevious, preventDefault: true },
317
+ { key: "ArrowUp", handler: focusPrevious, preventDefault: true },
318
+ { key: "Enter", handler: toggleFocusedMessage, preventDefault: true },
319
+ { key: "o", handler: toggleFocusedMessage, preventDefault: true },
320
+ ...(smtpConfigured
321
+ ? [
322
+ { key: "r", handler: handleReply, preventDefault: true },
323
+ {
324
+ key: "R",
325
+ handler: handleReplyAll,
326
+ noModifiers: false,
327
+ preventDefault: true,
328
+ },
329
+ { key: "f", handler: handleForward, preventDefault: true },
330
+ ]
331
+ : []),
332
+ ],
333
+ });
334
+
335
+ if (isLoading) {
336
+ return <LoadingSkeleton />;
337
+ }
338
+
339
+ if (isError) {
340
+ return (
341
+ <div className="flex h-full items-center justify-center">
342
+ <ErrorState
343
+ title="Couldn't load this conversation"
344
+ error={error}
345
+ onRetry={() => refetch()}
346
+ />
347
+ </div>
348
+ );
349
+ }
350
+
351
+ if (messages.length === 0) {
352
+ return (
353
+ <div className="flex h-full items-center justify-center">
354
+ <EmptyState message="No messages in this thread" />
355
+ </div>
356
+ );
357
+ }
358
+
359
+ const displaySubject = subject || messages[0]?.subject || "(No subject)";
360
+ const messageCount = messages.length;
361
+
362
+ // Message list wrapper — no extra x-padding; each MessageCard handles
363
+ // its own px-5 inset (matches the AppShell ReadingPane geometry). On mobile
364
+ // each expanded card owns a per-message action bar; reply verbs are wired
365
+ // only when SMTP is configured (otherwise the buttons no-op).
366
+ const renderMessages = (mobile: boolean) => (
367
+ <div>
368
+ {messages.map((message, index) => (
369
+ <div
370
+ key={message.threadMessageId}
371
+ ref={(el) => {
372
+ if (el) messageRefs.current.set(message.threadMessageId, el);
373
+ }}
374
+ >
375
+ <MessageCard
376
+ threadMessage={message}
377
+ isExpanded={expandedIds.has(message.threadMessageId)}
378
+ isFocused={index === focusedIndex}
379
+ onToggle={() => toggleExpanded(message.threadMessageId)}
380
+ onToggleStar={() => toggleStar(message.messageId, message.hasStars)}
381
+ isStarPending={
382
+ isStarPending && pendingMessageId === message.messageId
383
+ }
384
+ accountId={mailboxAccountId}
385
+ mobile={mobile}
386
+ onReply={mobile && smtpConfigured ? handleReply : undefined}
387
+ onReplyAll={mobile && smtpConfigured ? handleReplyAll : undefined}
388
+ onForward={mobile && smtpConfigured ? handleForward : undefined}
389
+ />
390
+ </div>
391
+ ))}
392
+ </div>
393
+ );
394
+
395
+ // Subject block: matches the AppShell ReadingPane reference exactly —
396
+ // px-5 pt-5 pb-3, text-lg leading-snug, 2xs count. Subject scrolls
397
+ // with the thread body; no subject in the toolbar chrome.
398
+ const header = (
399
+ <header className="border-b border-line px-5 pt-5 pb-3">
400
+ <h1 className="max-w-2xl text-lg font-semibold leading-snug text-fg">
401
+ {displaySubject}
402
+ </h1>
403
+ <p className="mt-1 text-2xs text-fg-subtle">
404
+ {messageCount} {messageCount === 1 ? "message" : "messages"}
405
+ </p>
406
+ </header>
407
+ );
408
+
409
+ // Mobile: the kit MobileReadingPane owns the chrome — a top app bar with
410
+ // back, the email subject and the intelligence toggle (the host owns the
411
+ // drawer). Each expanded card owns its per-message action bar; there is no
412
+ // thread-level reply footer. The phishing warning leads the scroll content;
413
+ // horizontal swipe between messages is wired through touchHandlers (#693).
414
+ if (!isDesktop) {
415
+ return (
416
+ <MobileReadingPane
417
+ thread={{ subject: displaySubject, messages: [] }}
418
+ onBack={onBack ?? (() => undefined)}
419
+ intelligenceOpen={mobileIntelligenceOpen}
420
+ onToggleIntelligence={onOpenIntelligence}
421
+ touchHandlers={swipeHandlers}
422
+ >
423
+ {authenticity?.dkimMismatch && (
424
+ <AuthenticityBanner
425
+ authenticity={authenticity}
426
+ onOpenIntelligence={onOpenIntelligence}
427
+ />
428
+ )}
429
+ {renderMessages(true)}
430
+ {composeMode !== null && (
431
+ <InlineCompose
432
+ mode={composeMode}
433
+ account={activeAccount}
434
+ sourceMessage={lastMessageData}
435
+ onClose={handleCloseCompose}
436
+ />
437
+ )}
438
+ </MobileReadingPane>
439
+ );
440
+ }
441
+
442
+ return (
443
+ <article className="h-full flex flex-col">
444
+ {header}
445
+ {authenticity?.dkimMismatch && (
446
+ <AuthenticityBanner
447
+ authenticity={authenticity}
448
+ onOpenIntelligence={onOpenIntelligence}
449
+ />
450
+ )}
451
+ <div className="flex-1 overflow-auto">{renderMessages(false)}</div>
452
+ {composeMode !== null && (
453
+ <InlineCompose
454
+ mode={composeMode}
455
+ account={activeAccount}
456
+ sourceMessage={lastMessageData}
457
+ onClose={handleCloseCompose}
458
+ />
459
+ )}
460
+ </article>
461
+ );
462
+ };