@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,1219 @@
1
+ /**
2
+ * MailboxPane — compound component for the mailbox view.
3
+ *
4
+ * Encapsulates all state, hooks, and rendering for the /mail/$mailboxId view.
5
+ * The parent (`mail.tsx`) mounts `<MailboxPane>` around the `AppShellSlotted`
6
+ * and passes the sub-views into slots:
7
+ *
8
+ * <MailboxPane mailboxId={...} selectedMessageId={...}>
9
+ * <AppShellSlotted
10
+ * list={<MailboxPane.List />}
11
+ * reading={<MailboxPane.Reading />}
12
+ * intelligence={<MailboxPane.Intelligence />}
13
+ * />
14
+ * </MailboxPane>
15
+ *
16
+ * On phone, use `<MailboxPane.Phone />` instead of the slot sub-views.
17
+ */
18
+ import {
19
+ outboxDetailOperationsDeleteOutboxMessageMutation,
20
+ outboxOperationsListOutboxMessagesQueryKey,
21
+ threadDetailOperationsListThreadMessagesQueryKey,
22
+ threadOperationsListThreadsQueryKey,
23
+ threadOperationsSearchThreadsQueryKey,
24
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
25
+ import {
26
+ threadOperationsListThreads,
27
+ threadOperationsSearchThreads,
28
+ } from "@remit/api-http-client/sdk.gen.ts";
29
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
30
+ import {
31
+ inboxFilterConfig,
32
+ ReadingPaneEmpty,
33
+ type RescueCandidate,
34
+ type SearchResult,
35
+ } from "@remit/ui";
36
+ import {
37
+ keepPreviousData,
38
+ useInfiniteQuery,
39
+ useMutation,
40
+ useQueryClient,
41
+ } from "@tanstack/react-query";
42
+ import { useNavigate, useSearch } from "@tanstack/react-router";
43
+ import {
44
+ createContext,
45
+ type ReactNode,
46
+ useCallback,
47
+ useContext,
48
+ useEffect,
49
+ useMemo,
50
+ useRef,
51
+ useState,
52
+ } from "react";
53
+ import type { ComposeMode } from "@/components/compose/ComposeProvider";
54
+ import { useCompose } from "@/components/compose/ComposeProvider";
55
+ import { FullCompose } from "@/components/compose/FullCompose";
56
+ import { Drawer } from "@/components/layout/Drawer";
57
+ import { ConversationView } from "@/components/mail/ConversationView";
58
+ import { DraftsView } from "@/components/mail/DraftsView";
59
+ import { IntelligencePane } from "@/components/mail/IntelligencePane";
60
+ import { MessageList } from "@/components/mail/MessageList";
61
+ import { MessageToolbar } from "@/components/mail/MessageToolbar";
62
+ import { PullToRefresh } from "@/components/mail/PullToRefresh";
63
+ import { SpamRescue } from "@/components/mail/SpamRescue";
64
+ import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
65
+ import { buildMutationErrorBanner } from "@/components/ui/error-banners";
66
+ import {
67
+ useArchiveMailbox,
68
+ useDraftsMailbox,
69
+ useJunkMailbox,
70
+ } from "@/hooks/useArchiveMailbox";
71
+ import {
72
+ useCurrentMailboxName,
73
+ useCurrentMailboxUnseenCount,
74
+ } from "@/hooks/useCurrentMailboxName";
75
+ import {
76
+ dropDeletedThreads,
77
+ useDeleteMessages,
78
+ } from "@/hooks/useDeleteMessages";
79
+ import { useIntelligenceData } from "@/hooks/useIntelligenceData";
80
+ import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
81
+ import { useLayoutTier } from "@/hooks/useLayoutTier";
82
+ import { useMailboxAccount } from "@/hooks/useMailboxAccount";
83
+ import { useToggleReadFor } from "@/hooks/useMarkAsRead";
84
+ import { useMoveMessages } from "@/hooks/useMoveMessages";
85
+ import { useRescueCandidates } from "@/hooks/useRescueCandidates";
86
+ import { useSemanticSearch } from "@/hooks/useSemanticSearch";
87
+ import { useToggleStar } from "@/hooks/useToggleStar";
88
+ import { useTriageKeyboard } from "@/hooks/useTriageKeyboard";
89
+ import { useUpdateAddressFlags } from "@/hooks/useUpdateAddressFlags";
90
+ import { adjacentMessageId } from "@/lib/adjacent-message";
91
+ import {
92
+ buildConversationTarget,
93
+ type ConversationTarget,
94
+ } from "@/lib/conversation-target";
95
+ import { readIntelligencePref } from "@/lib/intelligence-pref";
96
+ import { useMailContext } from "@/lib/mail-context";
97
+ import { isRescueCandidate } from "@/lib/rescue-candidates";
98
+ import { recordRescueSentToJunk } from "@/lib/rescue-telemetry";
99
+ import {
100
+ isSearchPending as computeIsSearchPending,
101
+ resolveSelectedThread,
102
+ } from "@/lib/search-pending";
103
+ import { normalizeSearchQuery } from "@/lib/search-query";
104
+ import {
105
+ relatedSearchResults,
106
+ threadToSearchResult,
107
+ } from "@/lib/search-result";
108
+ import { parseSearchTokens } from "@/lib/search-tokens";
109
+ import { useTelemetry } from "@/lib/telemetry-context";
110
+ import { MailViewChrome } from "./MailViewChrome";
111
+
112
+ /* ------------------------------------------------------------------ */
113
+ /* Inbox filter predicates — the inbox preset offers Unread / Flagged /
114
+ Has attachment (never accounts; an inbox is one account already). */
115
+ /* ------------------------------------------------------------------ */
116
+
117
+ const INBOX_FILTER_PREDICATES: Record<
118
+ string,
119
+ (t: RemitImapThreadMessageResponse) => boolean
120
+ > = {
121
+ unread: (t) => !t.isRead,
122
+ flagged: (t) => t.star != null && t.star !== "none" && t.hasStars === true,
123
+ attachment: (t) => Boolean(t.hasAttachment),
124
+ };
125
+
126
+ function applyInboxFilters(
127
+ threads: RemitImapThreadMessageResponse[],
128
+ category: string,
129
+ attributes: ReadonlySet<string>,
130
+ ): RemitImapThreadMessageResponse[] {
131
+ const predicates = Array.from(attributes)
132
+ .map((id) => INBOX_FILTER_PREDICATES[id])
133
+ .filter(
134
+ (p): p is (t: RemitImapThreadMessageResponse) => boolean => p != null,
135
+ );
136
+ if (category === "all" && predicates.length === 0) return threads;
137
+ return threads.filter(
138
+ (t) =>
139
+ (category === "all" || t.category === category) &&
140
+ predicates.every((p) => p(t)),
141
+ );
142
+ }
143
+
144
+ /* ------------------------------------------------------------------ */
145
+ /* Context */
146
+ /* ------------------------------------------------------------------ */
147
+
148
+ interface MailboxPaneContextValue {
149
+ mailboxId: string;
150
+ selectedMessageId: string | undefined;
151
+ selectedThread: RemitImapThreadMessageResponse | undefined;
152
+ /** The conversation to open — the loaded thread, or a tapped "Related" hit. */
153
+ conversation: ConversationTarget | undefined;
154
+ threads: RemitImapThreadMessageResponse[];
155
+ isLoading: boolean;
156
+ isError: boolean;
157
+ error: unknown;
158
+ mailboxAccountId: string | undefined;
159
+ mailboxName: string | null;
160
+ unreadCount: number;
161
+ isDraftsMailbox: boolean;
162
+ // Rescue-from-Spam: true on the account's Junk/Spam folder, with the
163
+ // suspected-safe messages over the loaded pages. Drives the rescue banner
164
+ // + flow above the spam list.
165
+ isSpamFolder: boolean;
166
+ rescueCandidates: RescueCandidate[];
167
+ // Inbox filter (category + Unread/Flagged/Attachment), applied client-side
168
+ // over the loaded threads. Owned here so the list, triage and adjacency all
169
+ // see the same filtered set; the open thread still resolves against the raw
170
+ // set so a filter never closes the reading pane.
171
+ filterCategory: string;
172
+ filterAttributes: ReadonlySet<string>;
173
+ onSelectFilterCategory: (id: string) => void;
174
+ onToggleFilterAttribute: (id: string) => void;
175
+ onClearFilters: () => void;
176
+ showIntelligence: boolean;
177
+ intelligenceOpen: boolean;
178
+ onToggleIntelligence: () => void;
179
+ // List actions
180
+ onDeleteMessages: (ids: string[]) => void;
181
+ onMoveMessages: (ids: string[], dest: string) => void;
182
+ isDeleting: boolean;
183
+ isMoving: boolean;
184
+ onLoadMore: () => void;
185
+ hasMore: boolean;
186
+ isLoadingMore: boolean;
187
+ onTriageContextChange: (ctx: {
188
+ focusedMessageId: string | undefined;
189
+ selectedIds: string[];
190
+ }) => void;
191
+ onRetry: () => void;
192
+ // Toolbar / reading pane actions
193
+ toolbarComposeRequest: ComposeMode | null;
194
+ onToolbarReply: () => void;
195
+ onToolbarReplyAll: () => void;
196
+ onToolbarForward: () => void;
197
+ onClearComposeRequest: () => void;
198
+ onToolbarDelete: () => void;
199
+ onToolbarStar: () => void;
200
+ onToolbarDiscardDraft: () => void;
201
+ onToolbarMove: (destMailboxId: string) => void;
202
+ composeState: ReturnType<typeof useCompose>["state"];
203
+ openCompose: () => void;
204
+ closeCompose: () => void;
205
+ hasRemitDraftOpen: boolean;
206
+ // Phone actions
207
+ onBack: () => void;
208
+ nextMessageId: string | undefined;
209
+ previousMessageId: string | undefined;
210
+ }
211
+
212
+ const MailboxPaneCtx = createContext<MailboxPaneContextValue | null>(null);
213
+
214
+ function useMailboxPane(): MailboxPaneContextValue {
215
+ const ctx = useContext(MailboxPaneCtx);
216
+ if (!ctx) throw new Error("MailboxPane.* must be used inside <MailboxPane>");
217
+ return ctx;
218
+ }
219
+
220
+ /* ------------------------------------------------------------------ */
221
+ /* Provider */
222
+ /* ------------------------------------------------------------------ */
223
+
224
+ interface MailboxPaneProps {
225
+ mailboxId: string;
226
+ selectedMessageId: string | undefined;
227
+ children: ReactNode;
228
+ }
229
+
230
+ function MailboxPaneProvider({
231
+ mailboxId,
232
+ selectedMessageId,
233
+ children,
234
+ }: MailboxPaneProps) {
235
+ const navigate = useNavigate();
236
+ const tier = useLayoutTier();
237
+ const isDesktop = tier === "desktop";
238
+ const telemetry = useTelemetry();
239
+ const {
240
+ accounts,
241
+ mailboxNameIndex,
242
+ accountNameIndex,
243
+ searchQuery,
244
+ searchInput,
245
+ intelligenceOpen,
246
+ onToggleIntelligence,
247
+ onSetIntelligenceOpen,
248
+ } = useMailContext();
249
+
250
+ const normalizedSearchQuery = normalizeSearchQuery(searchQuery);
251
+ const hasSearchQuery = normalizedSearchQuery.length > 0;
252
+ // Filter tokens (`from:`, `has:attachment`, `is:unread`) narrow this literal
253
+ // search to the params `threadOperationsSearchThreads` supports; `before:`/
254
+ // `after:`/`account:` have no equivalent on this endpoint and are left for
255
+ // the semantic section only (see `useSemanticSearch`). `in:` is likewise not
256
+ // applied here — this view is already scoped to one mailbox by its route —
257
+ // but still needs the name-index context so a resolved `in:`/`account:`
258
+ // token is stripped from `freeText` instead of leaking into the literal
259
+ // query text as a stray word.
260
+ const { freeText, tokens: searchTokens } = parseSearchTokens(
261
+ normalizedSearchQuery,
262
+ { mailboxesByName: mailboxNameIndex, accountsByName: accountNameIndex },
263
+ );
264
+ const fromToken = searchTokens.find((t) => t.type === "from");
265
+ const searchThreadsQuery = {
266
+ order: "desc" as const,
267
+ ...(freeText ? { query: freeText } : {}),
268
+ ...(fromToken ? { from: fromToken.value } : {}),
269
+ ...(searchTokens.some((t) => t.type === "hasAttachment")
270
+ ? { attachments: true }
271
+ : {}),
272
+ ...(searchTokens.some((t) => t.type === "isUnread")
273
+ ? { unread: true }
274
+ : {}),
275
+ };
276
+
277
+ const queryKey = hasSearchQuery
278
+ ? threadOperationsSearchThreadsQueryKey({
279
+ path: { mailboxId },
280
+ query: searchThreadsQuery,
281
+ })
282
+ : threadOperationsListThreadsQueryKey({
283
+ path: { mailboxId },
284
+ query: { order: "desc" },
285
+ });
286
+
287
+ const {
288
+ data: threadsData,
289
+ isLoading,
290
+ isError,
291
+ error,
292
+ refetch,
293
+ fetchNextPage,
294
+ hasNextPage,
295
+ isFetchingNextPage,
296
+ } = useInfiniteQuery({
297
+ queryKey,
298
+ queryFn: async ({ pageParam }) => {
299
+ if (hasSearchQuery) {
300
+ const { data } = await threadOperationsSearchThreads({
301
+ path: { mailboxId },
302
+ query: {
303
+ ...searchThreadsQuery,
304
+ continuationToken: pageParam,
305
+ },
306
+ throwOnError: true,
307
+ });
308
+ return data;
309
+ }
310
+ const { data } = await threadOperationsListThreads({
311
+ path: { mailboxId },
312
+ query: { order: "desc", continuationToken: pageParam },
313
+ throwOnError: true,
314
+ });
315
+ return data;
316
+ },
317
+ initialPageParam: undefined as string | undefined,
318
+ getNextPageParam: (lastPage) => lastPage.continuationToken,
319
+ enabled: hasSearchQuery ? normalizedSearchQuery.length > 0 : true,
320
+ placeholderData: keepPreviousData,
321
+ });
322
+
323
+ const handleDeselectIfRemoved = useCallback(
324
+ (removedIds: string[]) => {
325
+ if (!selectedMessageId) return;
326
+ if (!removedIds.includes(selectedMessageId)) return;
327
+ navigate({
328
+ to: "/mail/$mailboxId",
329
+ params: { mailboxId },
330
+ search: (prev: Record<string, unknown>) => ({
331
+ ...prev,
332
+ selectedMessageId: undefined,
333
+ selectedThreadId: undefined,
334
+ }),
335
+ });
336
+ },
337
+ [selectedMessageId, mailboxId, navigate],
338
+ );
339
+
340
+ const { deleteMessages: handleDeleteMessages, isPending: isDeleting } =
341
+ useDeleteMessages({
342
+ mailboxId,
343
+ onAfterOptimisticRemove: handleDeselectIfRemoved,
344
+ });
345
+
346
+ const { accountId: mailboxAccountId } = useMailboxAccount(mailboxId);
347
+ const mailboxName = useCurrentMailboxName({ accounts });
348
+
349
+ const { moveMessages: handleMoveMessages, isPending: isMoving } =
350
+ useMoveMessages({
351
+ mailboxId,
352
+ accountId: mailboxAccountId,
353
+ onAfterOptimisticRemove: handleDeselectIfRemoved,
354
+ });
355
+
356
+ const rawThreads = dropDeletedThreads(
357
+ threadsData?.pages.flatMap((page) => page.items ?? []) ?? [],
358
+ );
359
+
360
+ const [filterCategory, setFilterCategory] = useState("all");
361
+ const [filterAttributes, setFilterAttributes] = useState<ReadonlySet<string>>(
362
+ new Set(),
363
+ );
364
+ const threads = useMemo(
365
+ () => applyInboxFilters(rawThreads, filterCategory, filterAttributes),
366
+ [rawThreads, filterCategory, filterAttributes],
367
+ );
368
+
369
+ const onSelectFilterCategory = useCallback((id: string) => {
370
+ setFilterCategory(id);
371
+ }, []);
372
+ const onToggleFilterAttribute = useCallback((id: string) => {
373
+ setFilterAttributes((prev) => {
374
+ const next = new Set(prev);
375
+ if (next.has(id)) next.delete(id);
376
+ else next.add(id);
377
+ return next;
378
+ });
379
+ }, []);
380
+ const onClearFilters = useCallback(() => {
381
+ setFilterCategory("all");
382
+ setFilterAttributes(new Set());
383
+ }, []);
384
+
385
+ const isSearchPending = computeIsSearchPending(searchInput, searchQuery);
386
+ // Resolve the open thread against the raw set so an active filter never
387
+ // empties the reading pane on a message the user explicitly opened.
388
+ const selectedThread = resolveSelectedThread(
389
+ rawThreads,
390
+ selectedMessageId,
391
+ isSearchPending,
392
+ );
393
+
394
+ // A semantic "Related" hit can point at a message outside the loaded list, so
395
+ // it carries its threadId through the URL; fall back to it (the mailbox is the
396
+ // route param) so the conversation still opens.
397
+ const { selectedThreadId } = useSearch({ strict: false }) as {
398
+ selectedThreadId?: string;
399
+ };
400
+ const conversation = useMemo(
401
+ () =>
402
+ buildConversationTarget(selectedThread, {
403
+ messageId: selectedMessageId,
404
+ threadId: isSearchPending ? undefined : selectedThreadId,
405
+ mailboxId,
406
+ }),
407
+ [
408
+ selectedThread,
409
+ selectedMessageId,
410
+ selectedThreadId,
411
+ isSearchPending,
412
+ mailboxId,
413
+ ],
414
+ );
415
+
416
+ const [triageFocusedId, setTriageFocusedId] = useState<string | undefined>(
417
+ undefined,
418
+ );
419
+ const [triageSelectedIds, setTriageSelectedIds] = useState<string[]>([]);
420
+ const handleTriageContextChange = useCallback(
421
+ (context: {
422
+ focusedMessageId: string | undefined;
423
+ selectedIds: string[];
424
+ }) => {
425
+ setTriageFocusedId(context.focusedMessageId);
426
+ setTriageSelectedIds(context.selectedIds);
427
+ },
428
+ [],
429
+ );
430
+
431
+ const focusedThread =
432
+ threads.find((t) => t.messageId === triageFocusedId) ?? selectedThread;
433
+
434
+ // Auto-open intelligence pane on DKIM mismatch.
435
+ const autoOpenedForRef = useRef<string | null>(null);
436
+ useEffect(() => {
437
+ const id = selectedThread?.messageId ?? null;
438
+ if (!id) return;
439
+ if (autoOpenedForRef.current === id) return;
440
+ if (selectedThread?.authenticity?.dkimMismatch) {
441
+ autoOpenedForRef.current = id;
442
+ if (!intelligenceOpen) onToggleIntelligence();
443
+ }
444
+ }, [
445
+ selectedThread?.messageId,
446
+ selectedThread?.authenticity?.dkimMismatch,
447
+ intelligenceOpen,
448
+ onToggleIntelligence,
449
+ ]);
450
+
451
+ // Desktop default-open (#782)
452
+ const appliedDefaultRef = useRef(false);
453
+ useEffect(() => {
454
+ if (appliedDefaultRef.current) return;
455
+ if (!isDesktop) return;
456
+ if (!selectedThread?.messageId) return;
457
+ appliedDefaultRef.current = true;
458
+ if (readIntelligencePref() && !intelligenceOpen) {
459
+ onSetIntelligenceOpen(true);
460
+ }
461
+ }, [
462
+ isDesktop,
463
+ selectedThread?.messageId,
464
+ intelligenceOpen,
465
+ onSetIntelligenceOpen,
466
+ ]);
467
+
468
+ const mailboxUnseenCount = useCurrentMailboxUnseenCount({ accounts });
469
+ const unreadCount =
470
+ mailboxUnseenCount ?? rawThreads.filter((t) => !t.isRead).length;
471
+
472
+ const queryClient = useQueryClient();
473
+ const { pushError } = useErrorBanners();
474
+
475
+ const { deleteMessages: toolbarDelete } = useDeleteMessages({
476
+ mailboxId,
477
+ threadId: selectedThread?.threadId,
478
+ onAfterOptimisticRemove: handleDeselectIfRemoved,
479
+ });
480
+
481
+ const { moveMessages: toolbarMove } = useMoveMessages({
482
+ mailboxId,
483
+ threadId: selectedThread?.threadId,
484
+ accountId: mailboxAccountId,
485
+ onAfterOptimisticRemove: handleDeselectIfRemoved,
486
+ });
487
+
488
+ const { draftsMailboxId } = useDraftsMailbox(mailboxAccountId);
489
+ const isDraftsMailbox =
490
+ draftsMailboxId !== undefined && draftsMailboxId === mailboxId;
491
+
492
+ const { archiveMailboxId } = useArchiveMailbox(mailboxAccountId);
493
+
494
+ // Get thread message ids from cache; fall back to representative message id.
495
+ const getThreadMessageIds = useCallback(
496
+ (thread: RemitImapThreadMessageResponse) => {
497
+ const threadKey = threadDetailOperationsListThreadMessagesQueryKey({
498
+ path: { threadId: thread.threadId },
499
+ });
500
+ const cached = queryClient.getQueriesData<{
501
+ items: { messageId: string }[];
502
+ }>({ queryKey: threadKey });
503
+ const ids = cached.flatMap(
504
+ ([, data]) => data?.items.map((m) => m.messageId) ?? [],
505
+ );
506
+ return ids.length > 0 ? ids : [thread.messageId];
507
+ },
508
+ [queryClient],
509
+ );
510
+
511
+ const handleToolbarDelete = useCallback(() => {
512
+ if (!selectedThread) return;
513
+ const messageIds = getThreadMessageIds(selectedThread);
514
+ toolbarDelete(messageIds);
515
+ }, [selectedThread, getThreadMessageIds, toolbarDelete]);
516
+
517
+ const handleToolbarMove = useCallback(
518
+ (destMailboxId: string) => {
519
+ if (!selectedThread) return;
520
+ const messageIds = getThreadMessageIds(selectedThread);
521
+ toolbarMove(messageIds, destMailboxId);
522
+ },
523
+ [selectedThread, getThreadMessageIds, toolbarMove],
524
+ );
525
+
526
+ const { toggleStar: toolbarToggleStar } = useToggleStar({
527
+ threadId: selectedThread?.threadId ?? "",
528
+ mailboxId,
529
+ });
530
+
531
+ const handleToolbarStar = useCallback(() => {
532
+ if (!selectedThread) return;
533
+ toolbarToggleStar(selectedThread.messageId, selectedThread.hasStars);
534
+ }, [selectedThread, toolbarToggleStar]);
535
+
536
+ const [toolbarComposeRequest, setToolbarComposeRequest] =
537
+ useState<ComposeMode | null>(null);
538
+
539
+ const handleToolbarReply = useCallback(() => {
540
+ setToolbarComposeRequest("reply");
541
+ }, []);
542
+ const handleToolbarReplyAll = useCallback(() => {
543
+ setToolbarComposeRequest("reply_all");
544
+ }, []);
545
+ const handleToolbarForward = useCallback(() => {
546
+ setToolbarComposeRequest("forward");
547
+ }, []);
548
+ const handleClearComposeRequest = useCallback(() => {
549
+ setToolbarComposeRequest(null);
550
+ }, []);
551
+
552
+ const { state: composeState, openCompose, closeCompose } = useCompose();
553
+
554
+ const handleNewCompose = useCallback(() => {
555
+ openCompose({ mode: "new" });
556
+ }, [openCompose]);
557
+
558
+ const deleteOutboxMutation = useMutation({
559
+ ...outboxDetailOperationsDeleteOutboxMessageMutation(),
560
+ onError: (mutationError) => {
561
+ pushError(
562
+ buildMutationErrorBanner(
563
+ "Couldn't discard draft",
564
+ "The draft wasn't deleted.",
565
+ mutationError,
566
+ ),
567
+ );
568
+ },
569
+ });
570
+ const handleToolbarDiscardDraft = useCallback(() => {
571
+ const outboxMessageId = composeState.outboxMessageId;
572
+ if (!outboxMessageId) return;
573
+ deleteOutboxMutation.mutate({ path: { outboxMessageId } });
574
+ queryClient.invalidateQueries({
575
+ queryKey: outboxOperationsListOutboxMessagesQueryKey(),
576
+ });
577
+ closeCompose();
578
+ }, [
579
+ composeState.outboxMessageId,
580
+ deleteOutboxMutation,
581
+ queryClient,
582
+ closeCompose,
583
+ ]);
584
+
585
+ const goBack = useCallback(() => {
586
+ if (selectedMessageId) {
587
+ navigate({
588
+ to: "/mail/$mailboxId",
589
+ params: { mailboxId },
590
+ search: (prev: Record<string, unknown>) => ({
591
+ ...prev,
592
+ selectedMessageId: undefined,
593
+ selectedThreadId: undefined,
594
+ }),
595
+ });
596
+ }
597
+ }, [selectedMessageId, mailboxId, navigate]);
598
+
599
+ const messageIdsForFocusedThread = useCallback(
600
+ (thread: typeof focusedThread): string[] => {
601
+ if (!thread) return [];
602
+ return getThreadMessageIds(thread);
603
+ },
604
+ [getThreadMessageIds],
605
+ );
606
+
607
+ const { toggleStar: focusedToggleStar } = useToggleStar({
608
+ threadId: focusedThread?.threadId ?? "",
609
+ mailboxId,
610
+ });
611
+
612
+ const { toggleReadFor: triageToggleReadFor } = useToggleReadFor({
613
+ mailboxId,
614
+ accountId: mailboxAccountId,
615
+ });
616
+
617
+ const { junkMailboxId } = useJunkMailbox(mailboxAccountId);
618
+ const isSpamFolder = junkMailboxId != null && junkMailboxId === mailboxId;
619
+ const { candidates: rescueCandidates } = useRescueCandidates(
620
+ isSpamFolder ? junkMailboxId : undefined,
621
+ );
622
+ const { moveMessages: triageMove } = useMoveMessages({
623
+ mailboxId,
624
+ threadId: focusedThread?.threadId,
625
+ accountId: mailboxAccountId,
626
+ onAfterOptimisticRemove: handleDeselectIfRemoved,
627
+ });
628
+ const { deleteMessages: triageDelete } = useDeleteMessages({
629
+ mailboxId,
630
+ threadId: focusedThread?.threadId,
631
+ onAfterOptimisticRemove: handleDeselectIfRemoved,
632
+ });
633
+
634
+ const { addressId: focusedAddressId, address: focusedAddress } =
635
+ useIntelligenceData(focusedThread);
636
+ const { updateFlags: updateFocusedSenderFlags } = useUpdateAddressFlags({
637
+ addressId: focusedAddressId,
638
+ senderEmail: focusedThread?.fromEmail ?? undefined,
639
+ });
640
+
641
+ const ensureFocusedOpen = useCallback(() => {
642
+ if (selectedMessageId || !triageFocusedId) return false;
643
+ navigate({
644
+ to: "/mail/$mailboxId",
645
+ params: { mailboxId },
646
+ search: (prev: Record<string, unknown>) => ({
647
+ ...prev,
648
+ selectedMessageId: triageFocusedId,
649
+ }),
650
+ });
651
+ return true;
652
+ }, [selectedMessageId, triageFocusedId, mailboxId, navigate]);
653
+
654
+ const triageReply = useCallback(() => {
655
+ if (ensureFocusedOpen()) return;
656
+ if (selectedThread) setToolbarComposeRequest("reply");
657
+ }, [ensureFocusedOpen, selectedThread]);
658
+ const triageReplyAll = useCallback(() => {
659
+ if (ensureFocusedOpen()) return;
660
+ if (selectedThread) setToolbarComposeRequest("reply_all");
661
+ }, [ensureFocusedOpen, selectedThread]);
662
+ const triageForward = useCallback(() => {
663
+ if (ensureFocusedOpen()) return;
664
+ if (selectedThread) setToolbarComposeRequest("forward");
665
+ }, [ensureFocusedOpen, selectedThread]);
666
+
667
+ const triageTargetMessageIds = useCallback((): string[] => {
668
+ if (triageSelectedIds.length > 0) return triageSelectedIds;
669
+ return messageIdsForFocusedThread(focusedThread);
670
+ }, [triageSelectedIds, messageIdsForFocusedThread, focusedThread]);
671
+
672
+ const triageDeleteAction = useCallback(() => {
673
+ const ids = triageTargetMessageIds();
674
+ if (ids.length > 0) triageDelete(ids);
675
+ }, [triageTargetMessageIds, triageDelete]);
676
+
677
+ const triageMarkJunk = useCallback(() => {
678
+ if (!junkMailboxId) return;
679
+ const ids = triageTargetMessageIds();
680
+ if (ids.length === 0) return;
681
+ recordRescueSentToJunk(telemetry, {
682
+ count: ids.length,
683
+ senderTrust: focusedThread?.senderTrust ?? "unknown",
684
+ wasRescuable: focusedThread ? isRescueCandidate(focusedThread) : false,
685
+ });
686
+ triageMove(ids, junkMailboxId);
687
+ }, [
688
+ junkMailboxId,
689
+ triageTargetMessageIds,
690
+ triageMove,
691
+ telemetry,
692
+ focusedThread,
693
+ ]);
694
+
695
+ const triageStar = useCallback(() => {
696
+ if (triageSelectedIds.length > 0) {
697
+ const nextStarred = !(focusedThread?.hasStars ?? false);
698
+ const selected = new Set(triageSelectedIds);
699
+ for (const thread of threads) {
700
+ if (selected.has(thread.messageId) && thread.hasStars !== nextStarred) {
701
+ focusedToggleStar(thread.messageId, thread.hasStars);
702
+ }
703
+ }
704
+ return;
705
+ }
706
+ if (!focusedThread) return;
707
+ focusedToggleStar(focusedThread.messageId, focusedThread.hasStars);
708
+ }, [triageSelectedIds, threads, focusedThread, focusedToggleStar]);
709
+
710
+ const triageToggleRead = useCallback(() => {
711
+ const ids = triageTargetMessageIds();
712
+ if (ids.length === 0) return;
713
+ const nextRead = !(focusedThread?.isRead ?? false);
714
+ triageToggleReadFor(ids, nextRead);
715
+ }, [triageTargetMessageIds, focusedThread, triageToggleReadFor]);
716
+
717
+ const triageMute = useCallback(() => {
718
+ if (!focusedAddressId) return;
719
+ const next = !(focusedAddress?.flags?.muted?.value === true);
720
+ updateFocusedSenderFlags({ muted: { value: next } });
721
+ }, [focusedAddressId, focusedAddress, updateFocusedSenderFlags]);
722
+
723
+ const triageVip = useCallback(() => {
724
+ if (!focusedAddressId) return;
725
+ const next = !(focusedAddress?.flags?.vip?.value === true);
726
+ updateFocusedSenderFlags({ vip: { value: next } });
727
+ }, [focusedAddressId, focusedAddress, updateFocusedSenderFlags]);
728
+
729
+ const triageBlock = useCallback(() => {
730
+ if (!intelligenceOpen) onToggleIntelligence();
731
+ }, [intelligenceOpen, onToggleIntelligence]);
732
+
733
+ const goToRoute = useCallback(
734
+ (to: "/mail" | "/mail/flagged" | "/settings") => {
735
+ navigate({ to });
736
+ },
737
+ [navigate],
738
+ );
739
+
740
+ const mailboxType = isDraftsMailbox
741
+ ? "drafts"
742
+ : archiveMailboxId === mailboxId
743
+ ? "archive"
744
+ : junkMailboxId === mailboxId
745
+ ? "junk"
746
+ : "inbox";
747
+
748
+ const prevNormalizedSearchRef = useRef("");
749
+ useEffect(() => {
750
+ const prev = prevNormalizedSearchRef.current;
751
+ prevNormalizedSearchRef.current = normalizedSearchQuery;
752
+ if (normalizedSearchQuery.length > 0 && prev.length === 0) {
753
+ telemetry.recordEvent("search.submitted", { mailboxType });
754
+ }
755
+ }, [normalizedSearchQuery, mailboxType, telemetry]);
756
+
757
+ const hasThread = Boolean(selectedThread);
758
+ const hasRemitDraftOpen =
759
+ isDraftsMailbox &&
760
+ composeState.isOpen &&
761
+ !!composeState.outboxMessageId &&
762
+ !selectedThread;
763
+ const showIntelligence = isDesktop && intelligenceOpen && hasThread;
764
+
765
+ useTriageKeyboard({
766
+ enabled: !composeState.isOpen,
767
+ handlers: {
768
+ back: goBack,
769
+ reply: triageReply,
770
+ replyAll: triageReplyAll,
771
+ forward: triageForward,
772
+ delete: triageDeleteAction,
773
+ toggleStar: triageStar,
774
+ toggleRead: triageToggleRead,
775
+ muteSender: triageMute,
776
+ blockSender: triageBlock,
777
+ vipSender: triageVip,
778
+ markJunk: triageMarkJunk,
779
+ toggleIntelligence: selectedThread ? onToggleIntelligence : undefined,
780
+ compose: handleNewCompose,
781
+ goBrief: () => goToRoute("/mail"),
782
+ goInbox: () => goToRoute("/mail"),
783
+ goSent: () => goToRoute("/mail"),
784
+ goFlagged: () => goToRoute("/mail/flagged"),
785
+ goSettings: () => goToRoute("/settings"),
786
+ },
787
+ });
788
+
789
+ useKeyboardNavigation({
790
+ enabled: composeState.isOpen,
791
+ bindings: [{ key: "Escape", handler: closeCompose, preventDefault: true }],
792
+ });
793
+
794
+ const orderedMessageIds = threads.map((t) => t.messageId);
795
+ const nextMessageId =
796
+ adjacentMessageId(orderedMessageIds, selectedMessageId, "next") ??
797
+ undefined;
798
+ const previousMessageId =
799
+ adjacentMessageId(orderedMessageIds, selectedMessageId, "previous") ??
800
+ undefined;
801
+
802
+ const ctx: MailboxPaneContextValue = {
803
+ mailboxId,
804
+ selectedMessageId,
805
+ selectedThread,
806
+ conversation,
807
+ threads,
808
+ isLoading,
809
+ isError,
810
+ error,
811
+ mailboxAccountId,
812
+ mailboxName,
813
+ unreadCount,
814
+ isDraftsMailbox,
815
+ isSpamFolder,
816
+ rescueCandidates,
817
+ filterCategory,
818
+ filterAttributes,
819
+ onSelectFilterCategory,
820
+ onToggleFilterAttribute,
821
+ onClearFilters,
822
+ showIntelligence,
823
+ intelligenceOpen,
824
+ onToggleIntelligence,
825
+ onDeleteMessages: handleDeleteMessages,
826
+ onMoveMessages: handleMoveMessages,
827
+ isDeleting,
828
+ isMoving,
829
+ onLoadMore: fetchNextPage,
830
+ hasMore: hasNextPage,
831
+ isLoadingMore: isFetchingNextPage,
832
+ onTriageContextChange: handleTriageContextChange,
833
+ onRetry: () => refetch(),
834
+ toolbarComposeRequest,
835
+ onToolbarReply: handleToolbarReply,
836
+ onToolbarReplyAll: handleToolbarReplyAll,
837
+ onToolbarForward: handleToolbarForward,
838
+ onClearComposeRequest: handleClearComposeRequest,
839
+ onToolbarDelete: handleToolbarDelete,
840
+ onToolbarStar: handleToolbarStar,
841
+ onToolbarDiscardDraft: handleToolbarDiscardDraft,
842
+ onToolbarMove: handleToolbarMove,
843
+ composeState,
844
+ openCompose: handleNewCompose,
845
+ closeCompose,
846
+ hasRemitDraftOpen,
847
+ onBack: goBack,
848
+ nextMessageId,
849
+ previousMessageId,
850
+ };
851
+
852
+ return (
853
+ <MailboxPaneCtx.Provider value={ctx}>{children}</MailboxPaneCtx.Provider>
854
+ );
855
+ }
856
+
857
+ /* ------------------------------------------------------------------ */
858
+ /* Sub-views */
859
+ /* ------------------------------------------------------------------ */
860
+
861
+ /**
862
+ * List pane: MessageList or DraftsView.
863
+ * Mount in the `list` slot of `AppShellSlotted`.
864
+ */
865
+ function MailboxList() {
866
+ const {
867
+ mailboxId,
868
+ selectedMessageId,
869
+ threads,
870
+ isLoading,
871
+ isError,
872
+ error,
873
+ onDeleteMessages,
874
+ onMoveMessages,
875
+ isDeleting,
876
+ isMoving,
877
+ onLoadMore,
878
+ hasMore,
879
+ isLoadingMore,
880
+ mailboxAccountId,
881
+ mailboxName,
882
+ unreadCount,
883
+ isDraftsMailbox,
884
+ isSpamFolder,
885
+ rescueCandidates,
886
+ onTriageContextChange,
887
+ onRetry,
888
+ filterCategory,
889
+ filterAttributes,
890
+ onSelectFilterCategory,
891
+ onToggleFilterAttribute,
892
+ onClearFilters,
893
+ } = useMailboxPane();
894
+ const { searchQuery, searchInput, accounts } = useMailContext();
895
+ const tier = useLayoutTier();
896
+ const navigate = useNavigate();
897
+
898
+ const listTitle = mailboxName ?? "Inbox";
899
+ const preset = useMemo(() => inboxFilterConfig(), []);
900
+
901
+ const searchResults = useMemo(
902
+ () => threads.map(threadToSearchResult),
903
+ [threads],
904
+ );
905
+ // "Related" (semantic) is scoped to this mailbox and deduped against the
906
+ // literal "Top matches" by thread, so a thread never shows in both.
907
+ const { hits: semanticHits, isLoading: relatedLoading } = useSemanticSearch({
908
+ mailboxId,
909
+ filterCategory,
910
+ });
911
+ const relatedResults = useMemo(
912
+ () =>
913
+ relatedSearchResults(
914
+ semanticHits,
915
+ threads.map((t) => t.threadId),
916
+ ),
917
+ [semanticHits, threads],
918
+ );
919
+ const handleSelectSearchResult = useCallback(
920
+ (result: SearchResult) =>
921
+ navigate({
922
+ to: "/mail/$mailboxId",
923
+ params: { mailboxId },
924
+ search: (prev: Record<string, unknown>) => ({
925
+ ...prev,
926
+ // Commit the active query alongside the selection. The debounced
927
+ // q-mirror (mail.tsx) strips the selection whenever it sees the
928
+ // query go active; the row can be tapped before the debounce settles
929
+ // (it shows in the still-unfiltered list), so use the *live*
930
+ // `searchInput` here — committing `q` makes the mirror a no-op and
931
+ // keeps the opened result from being stripped out from under us.
932
+ q: searchInput || undefined,
933
+ selectedMessageId: result.id,
934
+ selectedThreadId: result.threadId,
935
+ }),
936
+ }),
937
+ [mailboxId, navigate, searchInput],
938
+ );
939
+
940
+ // Drafts keep their own dedicated view (and header); they don't carry the
941
+ // inbox category/attribute filter.
942
+ if (isDraftsMailbox && mailboxAccountId) {
943
+ return (
944
+ <DraftsView
945
+ mailboxId={mailboxId}
946
+ accountId={mailboxAccountId}
947
+ selectedMessageId={selectedMessageId}
948
+ imapThreads={threads}
949
+ title={mailboxName ?? "Drafts"}
950
+ unreadCount={unreadCount ?? undefined}
951
+ />
952
+ );
953
+ }
954
+
955
+ const messageList = (
956
+ <MessageList
957
+ mailboxId={mailboxId}
958
+ threads={threads}
959
+ selectedMessageId={selectedMessageId}
960
+ isLoading={isLoading}
961
+ isError={isError}
962
+ error={error}
963
+ onRetry={onRetry}
964
+ searchQuery={searchQuery}
965
+ onDeleteMessages={onDeleteMessages}
966
+ onMoveMessages={onMoveMessages}
967
+ isDeleting={isDeleting}
968
+ isMoving={isMoving}
969
+ onLoadMore={onLoadMore}
970
+ hasMore={hasMore}
971
+ isLoadingMore={isLoadingMore}
972
+ accountId={mailboxAccountId}
973
+ listTitle={listTitle}
974
+ hideHeader
975
+ onTriageContextChange={onTriageContextChange}
976
+ />
977
+ );
978
+
979
+ const phoneAccountId = accounts[0]?.accountId;
980
+ const listBody =
981
+ tier === "phone" && phoneAccountId ? (
982
+ <PullToRefresh accountId={phoneAccountId}>{messageList}</PullToRefresh>
983
+ ) : (
984
+ messageList
985
+ );
986
+
987
+ const body =
988
+ isSpamFolder && rescueCandidates.length > 0 && mailboxAccountId ? (
989
+ <SpamRescue
990
+ accountId={mailboxAccountId}
991
+ currentMailboxId={mailboxId}
992
+ candidates={rescueCandidates}
993
+ onMove={onMoveMessages}
994
+ >
995
+ {listBody}
996
+ </SpamRescue>
997
+ ) : (
998
+ listBody
999
+ );
1000
+
1001
+ return (
1002
+ <MailViewChrome
1003
+ title={listTitle}
1004
+ unreadCount={unreadCount}
1005
+ preset={preset}
1006
+ selectedCategory={filterCategory}
1007
+ activeFilters={filterAttributes}
1008
+ onSelectCategory={onSelectFilterCategory}
1009
+ onToggleFilter={onToggleFilterAttribute}
1010
+ onClearFilters={onClearFilters}
1011
+ searchResults={searchResults}
1012
+ searchLoading={isLoading}
1013
+ relatedResults={relatedResults}
1014
+ relatedLoading={relatedLoading}
1015
+ onSelectSearchResult={handleSelectSearchResult}
1016
+ >
1017
+ {body}
1018
+ </MailViewChrome>
1019
+ );
1020
+ }
1021
+
1022
+ /**
1023
+ * Reading pane: MessageToolbar + ConversationView / ReadingPaneEmpty.
1024
+ * Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
1025
+ */
1026
+ function MailboxReading() {
1027
+ const {
1028
+ mailboxId,
1029
+ mailboxAccountId,
1030
+ selectedThread,
1031
+ conversation,
1032
+ hasRemitDraftOpen,
1033
+ showIntelligence,
1034
+ onToggleIntelligence,
1035
+ toolbarComposeRequest,
1036
+ onToolbarReply,
1037
+ onToolbarReplyAll,
1038
+ onToolbarForward,
1039
+ onClearComposeRequest,
1040
+ onToolbarDelete,
1041
+ onToolbarStar,
1042
+ onToolbarDiscardDraft,
1043
+ onToolbarMove,
1044
+ composeState,
1045
+ openCompose,
1046
+ } = useMailboxPane();
1047
+ const { searchInput, onSearchChange, onSearchClear, onSearchClearQuery } =
1048
+ useMailContext();
1049
+ const tier = useLayoutTier();
1050
+ const isDesktop = tier === "desktop";
1051
+ const hasThread = Boolean(conversation);
1052
+
1053
+ const detailPane =
1054
+ composeState.isOpen && !conversation ? (
1055
+ <FullCompose />
1056
+ ) : conversation ? (
1057
+ <ConversationView
1058
+ threadId={conversation.threadId}
1059
+ mailboxId={conversation.mailboxId}
1060
+ subject={conversation.subject}
1061
+ selectedMessageId={conversation.messageId}
1062
+ authenticity={conversation.authenticity}
1063
+ onOpenIntelligence={
1064
+ conversation.authenticity?.dkimMismatch
1065
+ ? onToggleIntelligence
1066
+ : undefined
1067
+ }
1068
+ composeRequest={toolbarComposeRequest}
1069
+ onComposeClose={onClearComposeRequest}
1070
+ />
1071
+ ) : (
1072
+ <ReadingPaneEmpty />
1073
+ );
1074
+
1075
+ return (
1076
+ <section className="flex h-full w-full min-w-0 flex-col bg-canvas">
1077
+ <MessageToolbar
1078
+ hasThread={hasThread}
1079
+ onCompose={openCompose}
1080
+ intelligenceOpen={showIntelligence}
1081
+ showIntelligenceToggle={isDesktop && hasThread}
1082
+ onToggleIntelligence={onToggleIntelligence}
1083
+ searchValue={searchInput}
1084
+ onSearchChange={onSearchChange}
1085
+ onSearchClear={onSearchClear}
1086
+ onSearchClearQuery={onSearchClearQuery}
1087
+ onReply={hasThread ? onToolbarReply : undefined}
1088
+ onReplyAll={hasThread ? onToolbarReplyAll : undefined}
1089
+ onForward={hasThread ? onToolbarForward : undefined}
1090
+ canDelete={hasThread || hasRemitDraftOpen}
1091
+ onDelete={
1092
+ hasThread
1093
+ ? onToolbarDelete
1094
+ : hasRemitDraftOpen
1095
+ ? onToolbarDiscardDraft
1096
+ : undefined
1097
+ }
1098
+ onToggleStar={hasThread ? onToolbarStar : undefined}
1099
+ isStarred={selectedThread?.hasStars}
1100
+ moveContext={
1101
+ hasThread && mailboxAccountId
1102
+ ? {
1103
+ accountId: mailboxAccountId,
1104
+ currentMailboxId: mailboxId,
1105
+ onMove: onToolbarMove,
1106
+ }
1107
+ : undefined
1108
+ }
1109
+ />
1110
+ <div className="min-h-0 flex-1 overflow-hidden">{detailPane}</div>
1111
+ </section>
1112
+ );
1113
+ }
1114
+
1115
+ /**
1116
+ * Intelligence pane: IntelligencePane for the open thread.
1117
+ * Mount in the `intelligence` slot of `AppShellSlotted`. Only rendered ≥ 1280px.
1118
+ */
1119
+ function MailboxIntelligence() {
1120
+ const { mailboxId, mailboxAccountId, selectedThread, onToggleIntelligence } =
1121
+ useMailboxPane();
1122
+
1123
+ return (
1124
+ <IntelligencePane
1125
+ onClose={onToggleIntelligence}
1126
+ thread={selectedThread}
1127
+ mailboxId={mailboxId}
1128
+ accountId={mailboxAccountId}
1129
+ />
1130
+ );
1131
+ }
1132
+
1133
+ /**
1134
+ * Phone view: ConversationView (when a thread is open) or MessageList.
1135
+ * Use this on phones instead of the slot sub-views.
1136
+ */
1137
+ function MailboxPhone() {
1138
+ const {
1139
+ mailboxId,
1140
+ selectedThread,
1141
+ conversation,
1142
+ intelligenceOpen,
1143
+ onToggleIntelligence,
1144
+ onBack,
1145
+ nextMessageId,
1146
+ previousMessageId,
1147
+ composeState,
1148
+ } = useMailboxPane();
1149
+ const navigate = useNavigate();
1150
+
1151
+ if (conversation) {
1152
+ const openMessage = (messageId: string) =>
1153
+ navigate({
1154
+ to: "/mail/$mailboxId",
1155
+ params: { mailboxId },
1156
+ search: (prev: Record<string, unknown>) => ({
1157
+ ...prev,
1158
+ selectedMessageId: messageId,
1159
+ selectedThreadId: undefined,
1160
+ }),
1161
+ });
1162
+
1163
+ return (
1164
+ <>
1165
+ <ConversationView
1166
+ threadId={conversation.threadId}
1167
+ mailboxId={conversation.mailboxId}
1168
+ subject={conversation.subject}
1169
+ selectedMessageId={conversation.messageId}
1170
+ authenticity={conversation.authenticity}
1171
+ onBack={onBack}
1172
+ onOpenIntelligence={onToggleIntelligence}
1173
+ onSwipeNext={
1174
+ nextMessageId ? () => openMessage(nextMessageId) : undefined
1175
+ }
1176
+ onSwipePrevious={
1177
+ previousMessageId ? () => openMessage(previousMessageId) : undefined
1178
+ }
1179
+ mobileIntelligenceOpen={intelligenceOpen}
1180
+ />
1181
+ <Drawer
1182
+ isOpen={intelligenceOpen}
1183
+ onClose={onToggleIntelligence}
1184
+ ariaLabel="Message details"
1185
+ side="right"
1186
+ >
1187
+ <IntelligencePane
1188
+ onClose={onToggleIntelligence}
1189
+ thread={selectedThread}
1190
+ hideCloseButton
1191
+ />
1192
+ </Drawer>
1193
+ </>
1194
+ );
1195
+ }
1196
+
1197
+ if (composeState.isOpen) {
1198
+ return (
1199
+ <div className="h-full">
1200
+ <FullCompose />
1201
+ </div>
1202
+ );
1203
+ }
1204
+
1205
+ return <MailboxList />;
1206
+ }
1207
+
1208
+ /* ------------------------------------------------------------------ */
1209
+ /* Compound component assembly */
1210
+ /* ------------------------------------------------------------------ */
1211
+
1212
+ const MailboxPane = Object.assign(MailboxPaneProvider, {
1213
+ List: MailboxList,
1214
+ Reading: MailboxReading,
1215
+ Intelligence: MailboxIntelligence,
1216
+ Phone: MailboxPhone,
1217
+ });
1218
+
1219
+ export { MailboxPane };