@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,13 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { z } from "zod";
3
+
4
+ // Search schema: `selectedOutboxMessageId` is read by `OutboxPane` in the
5
+ // parent `/mail` shell via `useSearch({ strict: false })`.
6
+ const outboxSearchSchema = z.object({
7
+ selectedOutboxMessageId: z.string().optional(),
8
+ });
9
+
10
+ export const Route = createFileRoute("/mail/outbox")({
11
+ component: () => null,
12
+ validateSearch: outboxSearchSchema,
13
+ });
@@ -0,0 +1,413 @@
1
+ import {
2
+ configOperationsGetConfigOptions,
3
+ unifiedThreadOperationsListAllThreadsOptions,
4
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
+ import { AppShellSlotted } from "@remit/ui";
6
+ import { useQuery } from "@tanstack/react-query";
7
+ import {
8
+ createFileRoute,
9
+ Outlet,
10
+ useNavigate,
11
+ useRouterState,
12
+ useSearch,
13
+ } from "@tanstack/react-router";
14
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
15
+ import { z } from "zod";
16
+ import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
17
+ import { ComposeFab } from "@/components/layout/ComposeFab";
18
+ import { BriefPane } from "@/components/mail/BriefPane";
19
+ import { FlaggedPane } from "@/components/mail/FlaggedPane";
20
+ import { MailboxPane } from "@/components/mail/MailboxPane";
21
+ import { MailNav } from "@/components/mail/MailNav";
22
+ import { OutboxPane } from "@/components/mail/OutboxPane";
23
+ import { ErrorState } from "@/components/ui/ErrorState";
24
+ import { KeyboardShortcutsModal } from "@/components/ui/KeyboardShortcutsModal";
25
+ import { useDebouncedValue } from "@/hooks/useDebouncedValue";
26
+ import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
27
+ import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
28
+ import { useMailboxNameIndex } from "@/hooks/useMailboxNameIndex";
29
+ import { useStaleAccountSync } from "@/hooks/useStaleAccountSync";
30
+ import { writeIntelligencePref } from "@/lib/intelligence-pref";
31
+ import { MailContext } from "@/lib/mail-context";
32
+ import { isBriefRoute, isFlaggedRoute, isOutboxRoute } from "@/lib/mail-route";
33
+ import { buildAccountNameIndex } from "@/lib/search-token-index";
34
+ import "@/lib/client";
35
+
36
+ // `MailContext` / `useMailContext` live in `@/lib/mail-context` so the provider
37
+ // here and the child-route consumers resolve to a single module instance — see
38
+ // that file for why the alias-vs-relative route-tree import split otherwise
39
+ // breaks context.
40
+ export { useMailContext } from "@/lib/mail-context";
41
+
42
+ const mailSearchSchema = z.object({
43
+ q: z.string().optional(),
44
+ });
45
+
46
+ export const Route = createFileRoute("/mail")({
47
+ // Kick off config and the cross-account thread list together, without
48
+ // awaiting either — the route paints its skeleton shell immediately and the
49
+ // queries resolve in parallel rather than serialized behind config. The
50
+ // zero-account onboarding redirect moved into the component (see
51
+ // MailLayout), so a cold load no longer blocks first paint on the network.
52
+ loader: ({ context: { queryClient } }) => {
53
+ void queryClient.prefetchQuery(configOperationsGetConfigOptions());
54
+ void queryClient.prefetchQuery({
55
+ ...unifiedThreadOperationsListAllThreadsOptions(),
56
+ staleTime: 60_000,
57
+ });
58
+ },
59
+ component: MailLayout,
60
+ validateSearch: mailSearchSchema,
61
+ });
62
+
63
+ function MailLayout() {
64
+ const { q: searchQuery = "" } = useSearch({ from: "/mail" });
65
+ const navigate = useNavigate();
66
+ const tier = useLayoutTier();
67
+ // Below the reading boundary (phone AND tablet) AppShellSlotted shows a
68
+ // SINGLE pane — there is no reading pane to host the thread or the compose
69
+ // surface. So both tiers use the single-pane view, which swaps the pane in
70
+ // place between list, open thread, and compose. Keying this off "phone"
71
+ // alone left tablet with no compose surface (compose lives in the reading
72
+ // pane, which tablet doesn't mount) — the "c" shortcut / FAB opened nothing.
73
+ const isSinglePane = isSinglePaneTier(tier);
74
+ const [showShortcuts, setShowShortcuts] = useState(false);
75
+ const [drawerOpen, setDrawerOpen] = useState(false);
76
+ // Pane 4 / the mobile details drawer share this toggle. It starts closed so
77
+ // the phone never slams a full-screen intelligence drawer over a freshly
78
+ // opened thread; the DESKTOP route opens it by default with the thread (the
79
+ // intelligence rail is the product's core value) and honours the persisted
80
+ // collapse preference there (#782). DKIM-mismatch auto-open still fires on
81
+ // every tier. Explicit toggles persist the user's choice.
82
+ const [intelligenceOpen, setIntelligenceOpen] = useState(false);
83
+ const handleSetIntelligenceOpen = useCallback((open: boolean) => {
84
+ setIntelligenceOpen(open);
85
+ writeIntelligencePref(open);
86
+ }, []);
87
+
88
+ // URL `q` is a load-once seed for the input and a one-directional write
89
+ // target. The debounced local value is the source of truth and drives the
90
+ // search API; it is mirrored back to the URL but the URL is never read into
91
+ // state after mount.
92
+ const [searchInput, setSearchInput] = useState(searchQuery);
93
+ const debouncedSearchInput = useDebouncedValue(searchInput, 200);
94
+
95
+ const searchQueryRef = useRef(searchQuery);
96
+ searchQueryRef.current = searchQuery;
97
+
98
+ // Mirror the debounced search into the URL so links are shareable and a
99
+ // refresh restores the query. One-directional: the URL is never read back
100
+ // into state, so there is no sync loop.
101
+ // When a query *goes* active, also strip selectedMessageId so the reading
102
+ // pane closes (#539): an open message from the pre-search list is not
103
+ // meaningful in the search result set. Only strip on that transition though —
104
+ // tapping a search result commits the same `q` with the selection, so when
105
+ // `prev.q` already equals the query the result was opened under it (not a
106
+ // pre-search leftover) and must survive. The strip otherwise raced the tap:
107
+ // the row shows before the debounce settles, so this mirror can land just
108
+ // after the open and close it again.
109
+ useEffect(() => {
110
+ if (debouncedSearchInput === searchQueryRef.current) return;
111
+ navigate({
112
+ to: ".",
113
+ search: (prev) => {
114
+ const queryAlreadyActive =
115
+ (prev as { q?: string }).q === debouncedSearchInput;
116
+ return {
117
+ ...prev,
118
+ q: debouncedSearchInput || undefined,
119
+ ...(debouncedSearchInput && !queryAlreadyActive
120
+ ? {
121
+ selectedMessageId: undefined,
122
+ selectedThreadId: undefined,
123
+ selectedMailboxId: undefined,
124
+ }
125
+ : {}),
126
+ };
127
+ },
128
+ replace: true,
129
+ });
130
+ }, [debouncedSearchInput, navigate]);
131
+
132
+ const {
133
+ data: config,
134
+ isLoading,
135
+ isError: isConfigError,
136
+ error: configError,
137
+ refetch: refetchConfig,
138
+ } = useQuery({
139
+ ...configOperationsGetConfigOptions(),
140
+ // Config only changes when accounts are added/edited/removed; those
141
+ // mutations explicitly invalidate this query.
142
+ staleTime: Infinity,
143
+ });
144
+
145
+ // First-run guard: a zero-account user goes to the onboarding wizard. This
146
+ // lives here (not a blocking beforeLoad) so a cold load paints the skeleton
147
+ // shell immediately; the redirect fires once config arrives. It guards the
148
+ // /mail parent so it holds for every child route — index redirect, daily
149
+ // brief (#426/#484), etc.
150
+ const hasNoAccounts = Boolean(config && (config.accounts?.length ?? 0) === 0);
151
+ useEffect(() => {
152
+ if (!hasNoAccounts) return;
153
+ navigate({ to: "/onboarding", replace: true });
154
+ }, [hasNoAccounts, navigate]);
155
+
156
+ // Global keyboard shortcut for help
157
+ useKeyboardNavigation({
158
+ enabled: !showShortcuts,
159
+ bindings: [
160
+ {
161
+ key: "?",
162
+ handler: () => setShowShortcuts(true),
163
+ noModifiers: false, // Allow shift+/
164
+ preventDefault: true,
165
+ },
166
+ ],
167
+ });
168
+
169
+ const handleSearchChange = useCallback((query: string) => {
170
+ setSearchInput(query);
171
+ }, []);
172
+
173
+ // Clears the search field; the write effect drops `q` from the URL after
174
+ // the debounce settles.
175
+ const handleSearchClear = useCallback(() => {
176
+ setSearchInput("");
177
+ }, []);
178
+
179
+ // Esc inside the search field clears only the query (#489).
180
+ const handleSearchClearQuery = useCallback(() => {
181
+ setSearchInput("");
182
+ }, []);
183
+
184
+ const handleToggleIntelligence = useCallback(() => {
185
+ setIntelligenceOpen((open) => {
186
+ const next = !open;
187
+ writeIntelligencePref(next);
188
+ return next;
189
+ });
190
+ }, []);
191
+
192
+ const accounts = config?.accounts ?? [];
193
+ const mailboxNameIndex = useMailboxNameIndex(accounts);
194
+ const accountNameIndex = useMemo(
195
+ () => buildAccountNameIndex(accounts),
196
+ [accounts],
197
+ );
198
+
199
+ // Read the current mailbox params and selected message (if any) from the
200
+ // child route so the parent shell can mount the right pane (brief / mailbox /
201
+ // outbox) and forward the open thread into it.
202
+ //
203
+ // `useParams({ strict: false })` resolves against the *nearest* route match
204
+ // in the React component tree — which for this parent layout is /mail (no
205
+ // path params). The child's $mailboxId param never appears there, so
206
+ // mobileMailboxId would always be undefined and the navigate guard would
207
+ // short-circuit every tap. Use `useRouterState` instead: it exposes all
208
+ // currently matched routes, letting us find the one that carries mailboxId.
209
+ const mobileMailboxId = useRouterState({
210
+ select: (s) => {
211
+ const match = s.matches.find(
212
+ (m): m is typeof m & { params: { mailboxId: string } } =>
213
+ "mailboxId" in m.params,
214
+ );
215
+ return match?.params.mailboxId;
216
+ },
217
+ });
218
+ const { selectedMessageId: mobileSelectedMessageId } = useSearch({
219
+ strict: false,
220
+ }) as { selectedMessageId?: string };
221
+
222
+ // Detect which leaf route is active by its own routeId — never the parent
223
+ // /mail layout's pathname (which is "/mail" on EVERY child route and would
224
+ // wrongly route every mailbox through the brief pane, dropping message rows).
225
+ // See lib/mail-route.ts for the contract + regression test.
226
+ const onBriefRoute = useRouterState({
227
+ select: (s) => isBriefRoute(s.matches),
228
+ });
229
+ const onFlaggedRoute = useRouterState({
230
+ select: (s) => isFlaggedRoute(s.matches),
231
+ });
232
+ const onOutboxRoute = useRouterState({
233
+ select: (s) => isOutboxRoute(s.matches),
234
+ });
235
+
236
+ // Auto-trigger a mailbox-list sync for any account whose lastSyncAt is
237
+ // older than 15 minutes (or unset). Fires once per accountId per session
238
+ // — re-mounts of MailLayout will not retrigger. See #205.
239
+ useStaleAccountSync(accounts);
240
+
241
+ const handleMailboxSelect = useCallback(() => {
242
+ // Auto-collapse the mobile drawer after the user picks an inbox
243
+ // from the sidebar (#199). Desktop sidebar isn't a drawer so the
244
+ // noop is fine there.
245
+ setDrawerOpen(false);
246
+ }, []);
247
+
248
+ const mailContextValue = {
249
+ accounts,
250
+ mailboxNameIndex,
251
+ accountNameIndex,
252
+ // Debounced local value is the source of truth for search; it is
253
+ // mirrored to the URL one-directionally for shareable links.
254
+ searchQuery: debouncedSearchInput,
255
+ searchInput,
256
+ onSearchChange: handleSearchChange,
257
+ onSearchClear: handleSearchClear,
258
+ onSearchClearQuery: handleSearchClearQuery,
259
+ intelligenceOpen,
260
+ onToggleIntelligence: handleToggleIntelligence,
261
+ onSetIntelligenceOpen: handleSetIntelligenceOpen,
262
+ };
263
+
264
+ // Single nav node: the kit renders it as a pane (≥1024px) or inside its
265
+ // own slide-over Dialog (narrow), never both — so there is exactly one
266
+ // "Mailboxes" nav landmark at any width. `MailNav` adds the mobile
267
+ // Settings + bug-report footer when it detects the slide-over context.
268
+ const navContent = (
269
+ <MailNav accounts={accounts} onMailboxSelect={handleMailboxSelect} />
270
+ );
271
+ const overlayContent = <ComposeFab />;
272
+ const navSlideOver = {
273
+ navOpen: drawerOpen,
274
+ onOpenNav: () => setDrawerOpen(true),
275
+ onCloseNav: () => setDrawerOpen(false),
276
+ };
277
+
278
+ return (
279
+ <MailContext.Provider value={mailContextValue}>
280
+ {isConfigError ? (
281
+ <div className="flex h-full items-center justify-center bg-canvas p-4">
282
+ <ErrorState
283
+ title="Couldn't load your account"
284
+ error={configError}
285
+ onRetry={() => {
286
+ refetchConfig();
287
+ }}
288
+ />
289
+ </div>
290
+ ) : onBriefRoute ? (
291
+ // Daily brief (/mail/) — no mailboxId param; 2-pane layout (no intelligence).
292
+ <BriefPane selectedMessageId={mobileSelectedMessageId}>
293
+ {isSinglePane ? (
294
+ <AppShellSlotted
295
+ nav={navContent}
296
+ list={<BriefPane.Phone />}
297
+ intelligenceOpen={intelligenceOpen}
298
+ overlay={overlayContent}
299
+ skeleton={<AppShellSkeleton />}
300
+ isLoading={isLoading || hasNoAccounts}
301
+ {...navSlideOver}
302
+ />
303
+ ) : (
304
+ <AppShellSlotted
305
+ nav={navContent}
306
+ list={<BriefPane.List />}
307
+ reading={<BriefPane.Reading />}
308
+ intelligenceOpen={intelligenceOpen}
309
+ overlay={overlayContent}
310
+ skeleton={<AppShellSkeleton />}
311
+ isLoading={isLoading || hasNoAccounts}
312
+ {...navSlideOver}
313
+ />
314
+ )}
315
+ </BriefPane>
316
+ ) : onFlaggedRoute ? (
317
+ // Flagged virtual mailbox (/mail/flagged) — flat starred list across
318
+ // accounts; 2-pane layout (no intelligence), like the brief.
319
+ <FlaggedPane selectedMessageId={mobileSelectedMessageId}>
320
+ {isSinglePane ? (
321
+ <AppShellSlotted
322
+ nav={navContent}
323
+ list={<FlaggedPane.Phone />}
324
+ intelligenceOpen={intelligenceOpen}
325
+ overlay={overlayContent}
326
+ skeleton={<AppShellSkeleton />}
327
+ isLoading={isLoading || hasNoAccounts}
328
+ {...navSlideOver}
329
+ />
330
+ ) : (
331
+ <AppShellSlotted
332
+ nav={navContent}
333
+ list={<FlaggedPane.List />}
334
+ reading={<FlaggedPane.Reading />}
335
+ intelligenceOpen={intelligenceOpen}
336
+ overlay={overlayContent}
337
+ skeleton={<AppShellSkeleton />}
338
+ isLoading={isLoading || hasNoAccounts}
339
+ {...navSlideOver}
340
+ />
341
+ )}
342
+ </FlaggedPane>
343
+ ) : mobileMailboxId ? (
344
+ // Mailbox view (/mail/$mailboxId) — full 4-pane layout.
345
+ <MailboxPane
346
+ mailboxId={mobileMailboxId}
347
+ selectedMessageId={mobileSelectedMessageId}
348
+ >
349
+ {isSinglePane ? (
350
+ <AppShellSlotted
351
+ nav={navContent}
352
+ list={<MailboxPane.Phone />}
353
+ intelligenceOpen={intelligenceOpen}
354
+ overlay={overlayContent}
355
+ skeleton={<AppShellSkeleton />}
356
+ isLoading={isLoading || hasNoAccounts}
357
+ {...navSlideOver}
358
+ />
359
+ ) : (
360
+ <AppShellSlotted
361
+ nav={navContent}
362
+ list={<MailboxPane.List />}
363
+ reading={<MailboxPane.Reading />}
364
+ intelligence={<MailboxPane.Intelligence />}
365
+ intelligenceOpen={intelligenceOpen}
366
+ overlay={overlayContent}
367
+ skeleton={<AppShellSkeleton />}
368
+ isLoading={isLoading || hasNoAccounts}
369
+ {...navSlideOver}
370
+ />
371
+ )}
372
+ </MailboxPane>
373
+ ) : onOutboxRoute ? (
374
+ // Outbox — 2-pane layout (list + reading, no intelligence).
375
+ <OutboxPane>
376
+ {isSinglePane ? (
377
+ <AppShellSlotted
378
+ nav={navContent}
379
+ list={<OutboxPane.Phone />}
380
+ intelligenceOpen={false}
381
+ overlay={overlayContent}
382
+ skeleton={<AppShellSkeleton />}
383
+ isLoading={isLoading || hasNoAccounts}
384
+ {...navSlideOver}
385
+ />
386
+ ) : (
387
+ <AppShellSlotted
388
+ nav={navContent}
389
+ list={<OutboxPane.List />}
390
+ reading={<OutboxPane.Reading />}
391
+ intelligenceOpen={false}
392
+ overlay={overlayContent}
393
+ skeleton={<AppShellSkeleton />}
394
+ isLoading={isLoading || hasNoAccounts}
395
+ {...navSlideOver}
396
+ />
397
+ )}
398
+ </OutboxPane>
399
+ ) : (
400
+ // Fallback: transitioning or unrecognized route — show skeleton.
401
+ <AppShellSkeleton />
402
+ )}
403
+ <KeyboardShortcutsModal
404
+ isOpen={showShortcuts}
405
+ onClose={() => setShowShortcuts(false)}
406
+ />
407
+ {/* Outlet is required for TanStack Router to activate child routes.
408
+ Routes that manage their own rendering (brief, mailbox, outbox) return
409
+ null from their component — the parent shell owns the layout. */}
410
+ <Outlet />
411
+ </MailContext.Provider>
412
+ );
413
+ }
@@ -0,0 +1,35 @@
1
+ import { useQueryClient } from "@tanstack/react-query";
2
+ import { createFileRoute, useNavigate } from "@tanstack/react-router";
3
+ import { useCallback } from "react";
4
+ import { OnboardingWizard } from "@/components/onboarding/OnboardingWizard";
5
+ import { completeOnboarding } from "@/lib/onboarding-completion";
6
+ import { useTelemetry } from "@/lib/telemetry-context";
7
+
8
+ /**
9
+ * /onboarding — full-screen wizard for first-run users (zero accounts).
10
+ *
11
+ * Redirected to automatically by the /mail route when config.accounts is empty.
12
+ * After completing the wizard the user is sent to /mail (inbox).
13
+ */
14
+ export const Route = createFileRoute("/onboarding")({
15
+ component: OnboardingPage,
16
+ });
17
+
18
+ function OnboardingPage() {
19
+ const navigate = useNavigate();
20
+ const telemetry = useTelemetry();
21
+ const queryClient = useQueryClient();
22
+
23
+ const handleComplete = useCallback(
24
+ (_accountId: string) => {
25
+ void completeOnboarding({
26
+ queryClient,
27
+ recordCompleted: () => telemetry.recordEvent("onboarding.completed"),
28
+ navigateToInbox: () => void navigate({ to: "/mail" }),
29
+ });
30
+ },
31
+ [navigate, telemetry, queryClient],
32
+ );
33
+
34
+ return <OnboardingWizard skipWelcome={false} onComplete={handleComplete} />;
35
+ }
@@ -0,0 +1,65 @@
1
+ // biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
2
+ import assert from "node:assert";
3
+ import { describe, test } from "node:test";
4
+ import { mapOauthError } from "./accounts.tsx";
5
+
6
+ describe("mapOauthError", () => {
7
+ test("access_denied returns cancellation message", () => {
8
+ assert.strictEqual(
9
+ mapOauthError("access_denied"),
10
+ "You cancelled the sign-in.",
11
+ );
12
+ });
13
+
14
+ test("ACCESS_DENIED is case-insensitive", () => {
15
+ assert.strictEqual(
16
+ mapOauthError("ACCESS_DENIED"),
17
+ "You cancelled the sign-in.",
18
+ );
19
+ });
20
+
21
+ test("consent_required returns admin consent message", () => {
22
+ const result = mapOauthError("consent_required");
23
+ assert.ok(
24
+ result.toLowerCase().includes("admin"),
25
+ `Expected admin hint, got: ${result}`,
26
+ );
27
+ });
28
+
29
+ test("admin_consent_required returns admin consent message", () => {
30
+ const result = mapOauthError("admin_consent_required");
31
+ assert.ok(
32
+ result.toLowerCase().includes("admin"),
33
+ `Expected admin hint, got: ${result}`,
34
+ );
35
+ });
36
+
37
+ test("interaction_required returns admin consent message", () => {
38
+ const result = mapOauthError("interaction_required");
39
+ assert.ok(
40
+ result.toLowerCase().includes("admin"),
41
+ `Expected admin hint, got: ${result}`,
42
+ );
43
+ });
44
+
45
+ test("imap_disabled returns IMAP hint", () => {
46
+ const result = mapOauthError("imap_disabled");
47
+ assert.ok(
48
+ result.toLowerCase().includes("imap"),
49
+ `Expected IMAP hint, got: ${result}`,
50
+ );
51
+ });
52
+
53
+ test("unknown code returns generic fallback with the code", () => {
54
+ const result = mapOauthError("some_random_error");
55
+ assert.ok(
56
+ result.includes("some_random_error"),
57
+ `Expected code in message, got: ${result}`,
58
+ );
59
+ });
60
+
61
+ test("empty string returns generic fallback", () => {
62
+ const result = mapOauthError("");
63
+ assert.ok(typeof result === "string" && result.length > 0);
64
+ });
65
+ });