@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,187 @@
1
+ /**
2
+ * MailListHeader — the header-only list-pane shell shared by every list view.
3
+ *
4
+ * Composes the kit `MailHeader` (hamburger + title + unread + search) and the
5
+ * `<section>` column shell with an optional pinned footer. It owns no filter
6
+ * surface: the daily brief lets the kit `BriefSections` own the filter row,
7
+ * while `MailViewChrome` slots a `FilterSheet` into the body for the inbox /
8
+ * flagged views.
9
+ *
10
+ * Search comes from `MailContext` (one source of truth, mirrored to the URL).
11
+ * On phone the magnifier opens a full-screen `MobileSearchView` takeover instead
12
+ * of expanding over the title; tablet and desktop keep the inline header search
13
+ * and, once a query is present, swap the list-pane body to the same kit
14
+ * `SearchResults` sections under the shared `FilterSheet`. Consumers feed the
15
+ * filter chrome and query-narrowed results; both tiers render identical rows.
16
+ *
17
+ * Results split into two sections: "Top matches" (literal/instant search) and
18
+ * "Related" (semantic). The consumer dedupes them — a thread in both appears
19
+ * only under "Top matches". Each loads independently; an empty section drops out
20
+ * kit-side, so a "Related"-only result still shows when the literal search finds
21
+ * nothing. The hamburger opens the nav drawer via the enclosing `AppShellSlotted`.
22
+ */
23
+ import {
24
+ FilterSheet,
25
+ type FilterSheetProps,
26
+ MailHeader,
27
+ MobileSearchView,
28
+ type SearchResult,
29
+ type SearchResultSection,
30
+ SearchResults,
31
+ useAppShellLayout,
32
+ } from "@remit/ui";
33
+ import { type ReactNode, useState } from "react";
34
+ import { useLayoutTier } from "@/hooks/useLayoutTier";
35
+ import { useMailContext } from "@/lib/mail-context";
36
+ import { loadRecentSearches, saveRecentSearch } from "@/lib/recent-searches";
37
+ import {
38
+ parseSearchTokens,
39
+ removeSearchToken,
40
+ searchTokenLabel,
41
+ } from "@/lib/search-tokens";
42
+
43
+ interface MailListHeaderProps {
44
+ title: string;
45
+ unreadCount: number;
46
+ /** The list body (filter sheet / sections / virtualized rows). */
47
+ children: ReactNode;
48
+ /** Pinned below the scrollable list (e.g. the keyboard hint bar). */
49
+ footer?: ReactNode;
50
+ /** Filter chrome for the phone search takeover. Omit to drop the filter row. */
51
+ searchFilter?: Omit<FilterSheetProps, "children">;
52
+ /** Literal/instant results — the "Top matches" section. */
53
+ searchResults?: SearchResult[];
54
+ searchLoading?: boolean;
55
+ /** Semantic results — the "Related" section, deduped against "Top matches". */
56
+ relatedResults?: SearchResult[];
57
+ relatedLoading?: boolean;
58
+ onSelectSearchResult?: (result: SearchResult) => void;
59
+ }
60
+
61
+ export function MailListHeader({
62
+ title,
63
+ unreadCount,
64
+ children,
65
+ footer,
66
+ searchFilter,
67
+ searchResults,
68
+ searchLoading,
69
+ relatedResults,
70
+ relatedLoading,
71
+ onSelectSearchResult,
72
+ }: MailListHeaderProps) {
73
+ const {
74
+ searchInput,
75
+ onSearchChange,
76
+ onSearchClear,
77
+ mailboxNameIndex,
78
+ accountNameIndex,
79
+ } = useMailContext();
80
+ const layout = useAppShellLayout();
81
+ const tier = useLayoutTier();
82
+ const [searchOpen, setSearchOpen] = useState(false);
83
+ const [recentSearches, setRecentSearches] = useState(loadRecentSearches);
84
+
85
+ const hasQuery = searchInput.trim().length > 0;
86
+ // Filter tokens (`from:`, `has:attachment`, `in:`, `account:`, …) parsed live
87
+ // from the typed query render as removable chips above the sections;
88
+ // removing one edits the query text directly, which re-parses on the next
89
+ // render. `in:`/`account:` resolve against the shared name indexes so a
90
+ // chip only appears for a name that actually matches a mailbox/account.
91
+ const tokenChips = parseSearchTokens(searchInput, {
92
+ mailboxesByName: mailboxNameIndex,
93
+ accountsByName: accountNameIndex,
94
+ }).tokens.map((token) => ({
95
+ label: searchTokenLabel(token),
96
+ onRemove: () => onSearchChange(removeSearchToken(searchInput, token)),
97
+ }));
98
+ const topMatches = searchResults ?? [];
99
+ const related = relatedResults ?? [];
100
+ // Always offer both sections while a query is present; the kit drops the empty
101
+ // ones, so a "Related"-only hit still shows and two empties fall to its empty
102
+ // state. The empty-query case (recent searches) is the kit's job.
103
+ const sections: SearchResultSection[] = hasQuery
104
+ ? [
105
+ { id: "top", label: "Top matches", results: topMatches },
106
+ { id: "related", label: "Related", results: related },
107
+ ]
108
+ : [];
109
+ // Skeleton only while nothing is in yet — once either section has rows, show
110
+ // them and let the other arrive (or not). Keeps the two sources independent.
111
+ const hasAnyResult = topMatches.length + related.length > 0;
112
+ const resultsLoading =
113
+ !hasAnyResult && (searchLoading === true || relatedLoading === true);
114
+
115
+ if (tier === "phone" && searchOpen) {
116
+ const handleSelectResult = (result: SearchResult) => {
117
+ setRecentSearches(saveRecentSearch(searchInput));
118
+ setSearchOpen(false);
119
+ onSelectSearchResult?.(result);
120
+ };
121
+ return (
122
+ <MobileSearchView
123
+ value={searchInput}
124
+ onChange={onSearchChange}
125
+ onClear={onSearchClear}
126
+ onCancel={() => {
127
+ setSearchOpen(false);
128
+ onSearchClear();
129
+ }}
130
+ filter={searchFilter}
131
+ recentSearches={recentSearches}
132
+ onPickRecent={onSearchChange}
133
+ sections={sections}
134
+ loading={resultsLoading}
135
+ onSelectResult={handleSelectResult}
136
+ tokens={tokenChips}
137
+ />
138
+ );
139
+ }
140
+
141
+ // Tablet + desktop keep the inline toolbar search; once a query is present the
142
+ // list-pane body swaps to the same sectioned results the phone takeover shows,
143
+ // under the same FilterSheet. Clearing the query restores the normal list.
144
+ const showInlineResults = tier !== "phone" && hasQuery;
145
+ const handleSelectInlineResult = (result: SearchResult) => {
146
+ setRecentSearches(saveRecentSearch(searchInput));
147
+ onSelectSearchResult?.(result);
148
+ };
149
+ const results = (
150
+ <SearchResults
151
+ value={searchInput}
152
+ sections={sections}
153
+ loading={resultsLoading}
154
+ onSelectResult={handleSelectInlineResult}
155
+ tokens={tokenChips}
156
+ />
157
+ );
158
+ const body = !showInlineResults ? (
159
+ children
160
+ ) : searchFilter ? (
161
+ <FilterSheet {...searchFilter}>{results}</FilterSheet>
162
+ ) : (
163
+ <div className="h-full overflow-y-auto">{results}</div>
164
+ );
165
+
166
+ return (
167
+ <section className="flex h-full w-full flex-col bg-surface">
168
+ <MailHeader
169
+ title={title}
170
+ unreadCount={unreadCount}
171
+ // The list pane is narrow even on desktop, and the reading-pane
172
+ // toolbar owns the wide search; keep the header's search compact (a
173
+ // magnifier) at every width. On phone the magnifier opens the
174
+ // full-screen takeover above; on tablet it expands over the title.
175
+ isDesktop={false}
176
+ onMenuClick={() => layout?.openNav()}
177
+ searchValue={searchInput}
178
+ onSearchChange={onSearchChange}
179
+ onSearchClear={onSearchClear}
180
+ searchOpen={searchOpen}
181
+ onSearchOpenChange={setSearchOpen}
182
+ />
183
+ <div className="min-h-0 flex-1">{body}</div>
184
+ {footer}
185
+ </section>
186
+ );
187
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * MailNav — the nav slot for AppShellSlotted.
3
+ *
4
+ * Renders a single "Mailboxes" navigation landmark. The kit shows this
5
+ * node either as a persistent pane (≥1024px) or inside its own slide-over
6
+ * Dialog (narrow) — never both — so there is exactly one nav landmark at
7
+ * any width. Below 1024px the slide-over also carries a Settings +
8
+ * bug-report footer (#685), since the mobile layout has no message toolbar
9
+ * to host them.
10
+ */
11
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
12
+ import { useAppShellLayout } from "@remit/ui";
13
+ import { Link } from "@tanstack/react-router";
14
+ import { LogOut, Settings } from "lucide-react";
15
+ import { AccountSession } from "@/auth/AccountSession";
16
+ import { MailSidebarAdapter } from "@/components/mail/MailSidebarAdapter";
17
+ import { BugReportButton } from "@/components/ui/BugReportButton";
18
+
19
+ interface MailNavProps {
20
+ accounts: RemitImapAccountResponse[];
21
+ onMailboxSelect?: () => void;
22
+ }
23
+
24
+ export function MailNav({ accounts, onMailboxSelect }: MailNavProps) {
25
+ const layout = useAppShellLayout();
26
+ const isPane = layout?.showNavPane ?? true;
27
+
28
+ if (isPane) {
29
+ return <MailSidebarAdapter accounts={accounts} />;
30
+ }
31
+
32
+ return (
33
+ <div className="flex h-full flex-col">
34
+ <div className="flex-1 overflow-y-auto">
35
+ <MailSidebarAdapter
36
+ accounts={accounts}
37
+ onMailboxSelect={onMailboxSelect}
38
+ variant="drawer"
39
+ />
40
+ </div>
41
+ <div className="border-t border-line px-2 py-2">
42
+ <Link
43
+ to="/settings/accounts"
44
+ onClick={onMailboxSelect}
45
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-sm text-fg-muted transition-colors hover:bg-surface hover:text-fg"
46
+ >
47
+ <Settings className="size-4 shrink-0" />
48
+ <span className="flex-1 truncate text-left">Settings</span>
49
+ </Link>
50
+ <BugReportButton variant="drawer" />
51
+ <AccountSession>
52
+ {({ signOut }) => (
53
+ <button
54
+ type="button"
55
+ onClick={() => {
56
+ onMailboxSelect?.();
57
+ signOut();
58
+ }}
59
+ className="flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-1 text-sm text-fg-muted transition-colors hover:bg-surface hover:text-fg"
60
+ data-testid="sign-out-button"
61
+ >
62
+ <LogOut className="size-4 shrink-0" />
63
+ <span className="flex-1 truncate text-left">Sign out</span>
64
+ </button>
65
+ )}
66
+ </AccountSession>
67
+ </div>
68
+ </div>
69
+ );
70
+ }
@@ -0,0 +1,351 @@
1
+ import {
2
+ mailboxOperationsListMailboxesOptions,
3
+ outboxOperationsListOutboxMessagesOptions,
4
+ threadOperationsListThreadsQueryKey,
5
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
6
+ import type {
7
+ RemitImapAccountResponse,
8
+ RemitImapMailboxResponse,
9
+ } from "@remit/api-http-client/types.gen.ts";
10
+ import type {
11
+ NavAccount,
12
+ NavLinkComponent,
13
+ NavMailboxRole,
14
+ } from "@remit/ui";
15
+ import { NavSidebar } from "@remit/ui";
16
+ import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
17
+ import {
18
+ Link,
19
+ useLocation,
20
+ useNavigate,
21
+ useParams,
22
+ } from "@tanstack/react-router";
23
+ import { useCallback, useMemo, useState } from "react";
24
+ import { useTranslation } from "react-i18next";
25
+ import {
26
+ buildMailboxRoleMap,
27
+ labelForMailbox,
28
+ shouldShowUnreadBadgeForRole,
29
+ } from "@/lib/folder-roles";
30
+ import { useMailContext } from "@/lib/mail-context";
31
+ import { isOutboxListRow } from "@/lib/outbox-status";
32
+ import {
33
+ loadSavedSearches,
34
+ removeSavedSearch,
35
+ saveSearch,
36
+ } from "@/lib/saved-searches";
37
+
38
+ interface MailSidebarAdapterProps {
39
+ accounts: RemitImapAccountResponse[];
40
+ /**
41
+ * Fires after the user selects a mailbox entry. The mobile drawer wires this
42
+ * to close itself on selection (#199). Desktop callers omit it.
43
+ */
44
+ onMailboxSelect?: () => void;
45
+ /**
46
+ * "desktop" (default) wraps the nav in a full-height aside — Pane 1 of the
47
+ * 4-pane shell. "drawer" renders the nav body only so the mobile Drawer
48
+ * panel can pin a settings footer below it (#685).
49
+ */
50
+ variant?: "desktop" | "drawer";
51
+ }
52
+
53
+ type Translator = (key: string, fallback: string) => string;
54
+
55
+ const startsWithDigit = (str: string): boolean => /^\d/.test(str);
56
+
57
+ const compareLabelNames = (a: string, b: string): number => {
58
+ const aStartsWithDigit = startsWithDigit(a);
59
+ const bStartsWithDigit = startsWithDigit(b);
60
+ if (aStartsWithDigit && !bStartsWithDigit) return -1;
61
+ if (!aStartsWithDigit && bStartsWithDigit) return 1;
62
+ return a.localeCompare(b, undefined, { sensitivity: "base", numeric: true });
63
+ };
64
+
65
+ /**
66
+ * Split a mailbox list into pinned system rows and alpha-sorted plain
67
+ * folders. RFC 032 exclusive-folder-appointment (#976): "system" now means
68
+ * "appointed to a role in this account's folderAppointments map" — nothing is
69
+ * deduped here, because two folders can never share a role by construction.
70
+ * The kit re-pins/orders system rows by role itself; the adapter only needs
71
+ * to alpha-order the folders.
72
+ */
73
+ function sortMailboxes(
74
+ mailboxes: RemitImapMailboxResponse[],
75
+ roleMap: Map<string, NavMailboxRole>,
76
+ t: Translator,
77
+ ): {
78
+ system: RemitImapMailboxResponse[];
79
+ labels: RemitImapMailboxResponse[];
80
+ } {
81
+ const system: RemitImapMailboxResponse[] = [];
82
+ const labels: RemitImapMailboxResponse[] = [];
83
+
84
+ for (const mailbox of mailboxes) {
85
+ if (roleMap.has(mailbox.mailboxId)) {
86
+ system.push(mailbox);
87
+ } else {
88
+ labels.push(mailbox);
89
+ }
90
+ }
91
+
92
+ labels.sort((a, b) =>
93
+ compareLabelNames(
94
+ labelForMailbox(a, undefined, t),
95
+ labelForMailbox(b, undefined, t),
96
+ ),
97
+ );
98
+
99
+ return { system, labels };
100
+ }
101
+
102
+ /**
103
+ * Convert an API mailbox to a NavMailbox for the kit: `role` comes from the
104
+ * account's folder-role map (RFC 032 exclusive-folder-appointment, #976), the
105
+ * label follows `displayNameOverride` then the role's canonical name then the
106
+ * provider leaf, and the badge follows the role.
107
+ */
108
+ function toNavMailbox(
109
+ mb: RemitImapMailboxResponse,
110
+ role: NavMailboxRole | undefined,
111
+ t: Translator,
112
+ ) {
113
+ const showBadge = shouldShowUnreadBadgeForRole(role) && mb.unseenCount > 0;
114
+ return {
115
+ id: mb.mailboxId,
116
+ name: labelForMailbox(mb, role, t),
117
+ unseen: showBadge ? mb.unseenCount : undefined,
118
+ role,
119
+ fullPath: mb.fullPath,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Resolves the currently selected nav ID from the active route so the kit
125
+ * component can highlight the right item.
126
+ * - /mail/outbox → "outbox"
127
+ * - /mail/flagged → "flagged"
128
+ * - /mail/$mailboxId → mailboxId
129
+ * - /mail (daily brief) → "brief"
130
+ */
131
+ function useSelectedNavId(): string {
132
+ const location = useLocation();
133
+ const params = useParams({ strict: false }) as { mailboxId?: string };
134
+
135
+ if (location.pathname.startsWith("/mail/outbox")) return "outbox";
136
+ if (location.pathname.startsWith("/mail/flagged")) return "flagged";
137
+ if (params.mailboxId) return params.mailboxId;
138
+ if (location.pathname === "/mail" || location.pathname === "/mail/")
139
+ return "brief";
140
+ return "";
141
+ }
142
+
143
+ /**
144
+ * Pane 1 of the 4-pane shell. Data bridge that fetches mailboxes per account,
145
+ * maps them onto the kit's NavAccount shape, and renders each entry as a real
146
+ * router anchor via the kit's `linkComponent` render-prop. Replaces the retired
147
+ * local MailSidebar (#898).
148
+ */
149
+ export function MailSidebarAdapter({
150
+ accounts,
151
+ onMailboxSelect,
152
+ variant = "desktop",
153
+ }: MailSidebarAdapterProps) {
154
+ const queryClient = useQueryClient();
155
+ const navigate = useNavigate();
156
+ const selectedNavId = useSelectedNavId();
157
+ const { searchInput, onSearchChange } = useMailContext();
158
+ const [savedSearches, setSavedSearches] = useState(loadSavedSearches);
159
+ const { t } = useTranslation("mail", { useSuspense: false });
160
+ const translator: Translator = useCallback(
161
+ (key, fallback) => t(key, { defaultValue: fallback }),
162
+ [t],
163
+ );
164
+
165
+ const mailboxQueries = useQueries({
166
+ queries: accounts.map((account) => ({
167
+ ...mailboxOperationsListMailboxesOptions({
168
+ path: { accountId: account.accountId },
169
+ }),
170
+ // Mailboxes change rarely; cache forever, rely on explicit invalidation.
171
+ staleTime: Infinity,
172
+ })),
173
+ });
174
+
175
+ const { data: outboxResponse } = useQuery(
176
+ outboxOperationsListOutboxMessagesOptions(),
177
+ );
178
+
179
+ const outboxPendingCount = useMemo(
180
+ () =>
181
+ (outboxResponse?.items ?? []).filter((item) =>
182
+ isOutboxListRow(item.status),
183
+ ).length,
184
+ [outboxResponse?.items],
185
+ );
186
+
187
+ const navAccounts: NavAccount[] = useMemo(() => {
188
+ return accounts.map((account, i) => {
189
+ const query = mailboxQueries[i];
190
+ const mailboxes = query?.data?.items ?? [];
191
+ const roleMap = buildMailboxRoleMap(account.folderAppointments);
192
+ const { system, labels } = sortMailboxes(mailboxes, roleMap, translator);
193
+
194
+ const status: NavAccount["status"] = query?.isError
195
+ ? "error"
196
+ : query?.isLoading
197
+ ? "loading"
198
+ : "ready";
199
+
200
+ return {
201
+ id: account.accountId,
202
+ label: account.email,
203
+ email: account.email,
204
+ muted: Boolean(account.muted),
205
+ // Outbox is a shared singleton shown inside each account's section,
206
+ // matching existing sidebar behaviour.
207
+ outboxPending: outboxPendingCount,
208
+ status,
209
+ onRetry: () => query?.refetch(),
210
+ mailboxes: [...system, ...labels].map((mb) =>
211
+ toNavMailbox(mb, roleMap.get(mb.mailboxId), translator),
212
+ ),
213
+ };
214
+ });
215
+ }, [accounts, mailboxQueries, outboxPendingCount, translator]);
216
+
217
+ // Invalidate a mailbox's thread list on navigation so a re-visit always
218
+ // fetches fresh data (mirrors the retired MailboxItem behaviour).
219
+ const invalidateMailboxThreads = (mailboxId: string) => {
220
+ queryClient.invalidateQueries({
221
+ queryKey: threadOperationsListThreadsQueryKey({
222
+ path: { mailboxId },
223
+ query: { order: "desc" },
224
+ }),
225
+ });
226
+ };
227
+
228
+ const linkComponent: NavLinkComponent = ({
229
+ navId,
230
+ className,
231
+ ariaLabel,
232
+ title,
233
+ children,
234
+ onClick,
235
+ }) => {
236
+ if (navId === "brief") {
237
+ return (
238
+ <Link
239
+ to="/mail"
240
+ search={{ q: undefined, selectedMessageId: undefined }}
241
+ onClick={() => onClick?.()}
242
+ className={className}
243
+ aria-label={ariaLabel}
244
+ title={title}
245
+ >
246
+ {children}
247
+ </Link>
248
+ );
249
+ }
250
+ if (navId === "outbox") {
251
+ return (
252
+ <Link
253
+ to="/mail/outbox"
254
+ search={{ q: undefined, selectedOutboxMessageId: undefined }}
255
+ onClick={() => onClick?.()}
256
+ className={className}
257
+ aria-label={ariaLabel}
258
+ title={title}
259
+ >
260
+ {children}
261
+ </Link>
262
+ );
263
+ }
264
+ if (navId === "flagged") {
265
+ return (
266
+ <Link
267
+ to="/mail/flagged"
268
+ search={{ q: undefined, selectedMessageId: undefined }}
269
+ onClick={() => onClick?.()}
270
+ className={className}
271
+ aria-label={ariaLabel}
272
+ title={title}
273
+ >
274
+ {children}
275
+ </Link>
276
+ );
277
+ }
278
+ return (
279
+ <Link
280
+ to="/mail/$mailboxId"
281
+ params={{ mailboxId: navId }}
282
+ // Drop any stale search query / selected message when switching mailbox.
283
+ search={{ q: undefined, selectedMessageId: undefined }}
284
+ onClick={() => {
285
+ invalidateMailboxThreads(navId);
286
+ onClick?.();
287
+ }}
288
+ className={className}
289
+ aria-label={ariaLabel}
290
+ title={title}
291
+ >
292
+ {children}
293
+ </Link>
294
+ );
295
+ };
296
+
297
+ const handleSelectNav = () => {
298
+ // Navigation is handled by the router <Link>; this only runs the
299
+ // post-selection side effect (drawer auto-close on mobile).
300
+ onMailboxSelect?.();
301
+ };
302
+
303
+ // Saved searches (#428 follow-up, local-only MVP — see
304
+ // doc/design/flows/06-search.md). A saved query is the raw typed text; the
305
+ // existing token parser re-derives its chips on reselect, so there's nothing
306
+ // else to persist.
307
+ const trimmedSearchInput = searchInput.trim();
308
+ const saveableQuery =
309
+ trimmedSearchInput.length > 0 && !savedSearches.includes(trimmedSearchInput)
310
+ ? trimmedSearchInput
311
+ : undefined;
312
+
313
+ const handleSaveCurrentSearch = useCallback(() => {
314
+ if (!saveableQuery) return;
315
+ setSavedSearches(saveSearch(saveableQuery));
316
+ }, [saveableQuery]);
317
+
318
+ const handleRemoveSavedSearch = useCallback((query: string) => {
319
+ setSavedSearches(removeSavedSearch(query));
320
+ }, []);
321
+
322
+ // Running a saved search re-uses the daily brief as the search surface (the
323
+ // cross-account default view) — the same destination the search field
324
+ // itself lands results in once a query is active.
325
+ const handleSelectSavedSearch = useCallback(
326
+ (query: string) => {
327
+ onSearchChange(query);
328
+ navigate({
329
+ to: "/mail",
330
+ search: { q: query, selectedMessageId: undefined },
331
+ });
332
+ onMailboxSelect?.();
333
+ },
334
+ [onSearchChange, navigate, onMailboxSelect],
335
+ );
336
+
337
+ return (
338
+ <NavSidebar
339
+ accounts={navAccounts}
340
+ selectedNavId={selectedNavId}
341
+ onSelectNav={handleSelectNav}
342
+ linkComponent={linkComponent}
343
+ variant={variant}
344
+ savedSearches={savedSearches}
345
+ saveableQuery={saveableQuery}
346
+ onSaveCurrentSearch={handleSaveCurrentSearch}
347
+ onSelectSavedSearch={handleSelectSavedSearch}
348
+ onRemoveSavedSearch={handleRemoveSavedSearch}
349
+ />
350
+ );
351
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * MailViewChrome — the shared list-pane chrome for the inbox and flagged views.
3
+ *
4
+ * Wraps the header-only `MailListHeader` and slots the `FilterSheet` expando
5
+ * directly into its body, exactly as the kit story does. The caller supplies the
6
+ * filter preset (`inboxFilterConfig` / `flaggedFilterConfig`) and owns the
7
+ * category / attribute / source selection state. The same filter config feeds
8
+ * the phone search takeover (via `MailListHeader`), so filters carry across.
9
+ *
10
+ * The daily brief no longer uses this: it composes `MailListHeader` with the kit
11
+ * `BriefSections`, which owns its own filter row (so there is exactly one filter
12
+ * surface and the section headers flatten correctly when filtered).
13
+ */
14
+ import {
15
+ type FilterPreset,
16
+ FilterSheet,
17
+ type FilterSheetProps,
18
+ type SearchResult,
19
+ } from "@remit/ui";
20
+ import { type ReactNode, useState } from "react";
21
+ import { MailListHeader } from "./MailListHeader";
22
+
23
+ interface MailViewChromeProps {
24
+ title: string;
25
+ unreadCount: number;
26
+ preset: FilterPreset;
27
+ selectedCategory: string;
28
+ activeFilters: ReadonlySet<string>;
29
+ onSelectCategory: (id: string) => void;
30
+ onToggleFilter: (id: string) => void;
31
+ onSelectSource?: (id: string) => void;
32
+ onClearFilters: () => void;
33
+ /** The list body (sections / virtualized rows) rendered inside the expando. */
34
+ children: ReactNode;
35
+ /** Pinned below the scrollable list (e.g. the keyboard hint bar). */
36
+ footer?: ReactNode;
37
+ /** Literal/instant results — the "Top matches" section. */
38
+ searchResults?: SearchResult[];
39
+ searchLoading?: boolean;
40
+ /** Semantic results — the "Related" section. */
41
+ relatedResults?: SearchResult[];
42
+ relatedLoading?: boolean;
43
+ onSelectSearchResult?: (result: SearchResult) => void;
44
+ }
45
+
46
+ export function MailViewChrome({
47
+ title,
48
+ unreadCount,
49
+ preset,
50
+ selectedCategory,
51
+ activeFilters,
52
+ onSelectCategory,
53
+ onToggleFilter,
54
+ onSelectSource,
55
+ onClearFilters,
56
+ children,
57
+ footer,
58
+ searchResults,
59
+ searchLoading,
60
+ relatedResults,
61
+ relatedLoading,
62
+ onSelectSearchResult,
63
+ }: MailViewChromeProps) {
64
+ const [expanded, setExpanded] = useState(false);
65
+
66
+ const filterConfig: Omit<FilterSheetProps, "children"> = {
67
+ categories: preset.categories,
68
+ filters: preset.filters,
69
+ sources: preset.sources,
70
+ selectedCategory,
71
+ activeFilters,
72
+ expanded,
73
+ onExpandedChange: setExpanded,
74
+ onSelectCategory,
75
+ onSelectSource,
76
+ onToggleFilter,
77
+ onClear: onClearFilters,
78
+ };
79
+
80
+ return (
81
+ <MailListHeader
82
+ title={title}
83
+ unreadCount={unreadCount}
84
+ footer={footer}
85
+ searchFilter={filterConfig}
86
+ searchResults={searchResults}
87
+ searchLoading={searchLoading}
88
+ relatedResults={relatedResults}
89
+ relatedLoading={relatedLoading}
90
+ onSelectSearchResult={onSelectSearchResult}
91
+ >
92
+ <FilterSheet {...filterConfig}>{children}</FilterSheet>
93
+ </MailListHeader>
94
+ );
95
+ }