@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,451 @@
1
+ /**
2
+ * DailyBrief — unified cross-account message digest.
3
+ *
4
+ * Renders one section per message category (Personal / Transactional /
5
+ * Newsletter / Marketing / Social / Automated) from the GET /threads endpoint.
6
+ * Starred mail is not a section — Flagged is a virtual mailbox in the nav. The
7
+ * brief defaults to the cross-account aggregate; the kit `BriefSections` owns
8
+ * the filter row (categories + attribute chips + the account source group) and
9
+ * the flatten-when-filtered behavior, while `MailListHeader` provides the title,
10
+ * unread count, and search. Account switching also lives in the nav sidebar —
11
+ * the account source group only appears when more than one account feeds the
12
+ * brief.
13
+ *
14
+ * Loading: skeleton rows on first paint, patch-in-place on refetch.
15
+ * Error: per-section; the brief still renders other sections.
16
+ * Empty: "You're caught up" message.
17
+ */
18
+ import {
19
+ mailboxOperationsListMailboxesOptions,
20
+ unifiedThreadOperationsListAllThreadsOptions,
21
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
22
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
23
+ import {
24
+ Avatar,
25
+ type BriefCategoryFilter,
26
+ BriefSections,
27
+ briefFilterConfig,
28
+ ComfortableRowTextContent,
29
+ cn,
30
+ comfortableRowClass,
31
+ type FilterSheetProps,
32
+ type FilterSheetSource,
33
+ KeyboardHintBar,
34
+ type SearchResult,
35
+ type ThreadRowData,
36
+ type ThreadSection,
37
+ } from "@remit/ui";
38
+ import { useQueries, useQuery } from "@tanstack/react-query";
39
+ import { useNavigate } from "@tanstack/react-router";
40
+ import { AlertCircle, RefreshCw, Sparkles } from "lucide-react";
41
+ import { useCallback, useMemo, useState } from "react";
42
+ import { useIsDesktop } from "@/hooks/useMediaQuery";
43
+ import { useSemanticSearch } from "@/hooks/useSemanticSearch";
44
+ import { sortAccountsByCreatedAt } from "@/lib/account-order";
45
+ import {
46
+ groupBriefSections,
47
+ matchesBriefSearch,
48
+ matchesSearchTokens,
49
+ toThreadRowData,
50
+ } from "@/lib/brief";
51
+ import { isServerError } from "@/lib/error-classifier";
52
+ import { useMailContext } from "@/lib/mail-context";
53
+ import { relatedSearchResults, rowToSearchResult } from "@/lib/search-result";
54
+ import { parseSearchTokens } from "@/lib/search-tokens";
55
+ import { MailListHeader } from "./MailListHeader";
56
+
57
+ /* The brief's attribute chips as predicates (mirrors the kit `briefFilterChips`
58
+ ids) so the phone search takeover narrows results the same way the list does. */
59
+ const BRIEF_SEARCH_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
60
+ unread: (t) => !t.isRead,
61
+ attachment: (t) => t.hasAttachment === true,
62
+ contacts: (t) => t.trust === "vip" || t.trust === "wellknown",
63
+ today: (t) =>
64
+ t.sentDate != null &&
65
+ new Date(t.sentDate).toDateString() === new Date().toDateString(),
66
+ };
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Skeleton
70
+ // ---------------------------------------------------------------------------
71
+
72
+ const SectionSkeleton = () => (
73
+ <div className="animate-pulse">
74
+ {Array.from({ length: 3 }).map((_, i) => (
75
+ <div
76
+ // biome-ignore lint/suspicious/noArrayIndexKey: skeleton rows have no stable id
77
+ key={i}
78
+ className="flex items-start gap-3 py-2 pl-5 pr-4 border-b border-line"
79
+ >
80
+ <div className="size-7 rounded-full bg-surface-sunken shrink-0 mt-0.5" />
81
+ <div className="flex-1 space-y-1.5">
82
+ <div className="flex justify-between gap-2">
83
+ <div className="h-3.5 bg-surface-sunken rounded w-28" />
84
+ <div className="h-3 bg-surface-sunken rounded w-12" />
85
+ </div>
86
+ <div className="h-3.5 bg-surface-sunken rounded w-48" />
87
+ <div className="h-3 bg-surface-sunken rounded w-full" />
88
+ </div>
89
+ </div>
90
+ ))}
91
+ </div>
92
+ );
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Error banner (per-account connection failure)
96
+ // ---------------------------------------------------------------------------
97
+
98
+ interface ErrorBannerProps {
99
+ accountEmail: string;
100
+ accountId: string;
101
+ }
102
+
103
+ const ErrorBanner = ({ accountEmail }: ErrorBannerProps) => {
104
+ const navigate = useNavigate();
105
+ return (
106
+ <div className="flex items-center gap-2 px-row-inset py-2 border-b border-line bg-danger-soft/40 text-xs text-danger">
107
+ <AlertCircle className="size-3.5 shrink-0" />
108
+ <span className="flex-1 truncate">{accountEmail} can't connect</span>
109
+ <button
110
+ type="button"
111
+ onClick={() => navigate({ to: "/settings/accounts" })}
112
+ className="shrink-0 underline text-danger hover:opacity-80"
113
+ >
114
+ Reconnect
115
+ </button>
116
+ </div>
117
+ );
118
+ };
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Brief row — a navigation-aware row satisfying remit-ui's BriefRowComponent
122
+ // ---------------------------------------------------------------------------
123
+
124
+ const BriefRow = ({
125
+ thread,
126
+ active,
127
+ onClick,
128
+ }: {
129
+ thread: ThreadRowData;
130
+ active?: boolean;
131
+ onClick?: () => void;
132
+ }) => {
133
+ const unread = !thread.isRead;
134
+ return (
135
+ <button
136
+ type="button"
137
+ onClick={onClick}
138
+ className={cn("group w-full", comfortableRowClass({ active }))}
139
+ >
140
+ {unread && (
141
+ <span className="absolute left-1.5 top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-accent" />
142
+ )}
143
+ <Avatar name={thread.fromName} email={thread.fromEmail} size="sm" />
144
+ <ComfortableRowTextContent thread={thread} />
145
+ </button>
146
+ );
147
+ };
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Main component
151
+ // ---------------------------------------------------------------------------
152
+
153
+ interface DailyBriefProps {
154
+ accounts: RemitImapAccountResponse[];
155
+ selectedMessageId?: string;
156
+ /** Opens an in-list brief row (resolved by messageId against the loaded list). */
157
+ onSelectMessage?: (id: string) => void;
158
+ /**
159
+ * Opens a search result. A semantic "Related" hit carries its thread + mailbox
160
+ * so it opens even when its message isn't in the loaded brief list.
161
+ */
162
+ onSelectSearchResult?: (result: SearchResult) => void;
163
+ }
164
+
165
+ export function DailyBrief({
166
+ accounts,
167
+ selectedMessageId,
168
+ onSelectMessage,
169
+ onSelectSearchResult,
170
+ }: DailyBriefProps) {
171
+ const { searchQuery, mailboxNameIndex, accountNameIndex } = useMailContext();
172
+ const isDesktop = useIsDesktop();
173
+
174
+ const nonMuted = useMemo(
175
+ () => sortAccountsByCreatedAt(accounts.filter((a) => !a.muted?.value)),
176
+ [accounts],
177
+ );
178
+
179
+ // "all" = the cross-account aggregate (the brief's default). Account
180
+ // switching also lives in the nav sidebar; this source group is a convenience
181
+ // shown only when more than one account feeds the brief. The category axis and
182
+ // attribute chips are owned by the kit `BriefSections` filter row; the brief
183
+ // only controls the category (so it can drive the flatten-when-filtered path)
184
+ // and the account source.
185
+ const [selectedAccountId, setSelectedAccountId] = useState("all");
186
+ const [selectedCategory, setSelectedCategory] =
187
+ useState<BriefCategoryFilter>("all");
188
+
189
+ // Attribute chips for the phone search takeover. The brief list's own chips
190
+ // live inside the kit `BriefSections`; the takeover is a separate surface, so
191
+ // it carries its own additive set (category + account are shared above).
192
+ const [searchAttributes, setSearchAttributes] = useState<ReadonlySet<string>>(
193
+ new Set(),
194
+ );
195
+ const [searchExpanded, setSearchExpanded] = useState(false);
196
+ const toggleSearchAttribute = useCallback((id: string) => {
197
+ setSearchAttributes((prev) => {
198
+ const next = new Set(prev);
199
+ if (next.has(id)) next.delete(id);
200
+ else next.add(id);
201
+ return next;
202
+ });
203
+ }, []);
204
+
205
+ // --- Unified threads query ---
206
+ const {
207
+ data: threadsData,
208
+ isLoading,
209
+ isError,
210
+ refetch,
211
+ } = useQuery({
212
+ ...unifiedThreadOperationsListAllThreadsOptions(),
213
+ staleTime: 60_000,
214
+ });
215
+
216
+ // --- Per-account mailbox list for unread counts and error detection ---
217
+ const mailboxQueries = useQueries({
218
+ queries: nonMuted.map((account) => ({
219
+ ...mailboxOperationsListMailboxesOptions({
220
+ path: { accountId: account.accountId },
221
+ }),
222
+ staleTime: Infinity,
223
+ // A 4xx here is the account's own problem (IMAP down, auth expired) and
224
+ // renders the inline "Reconnect" banner below — opt it out of the global
225
+ // fatal overlay. A 5xx is OUR API breaking, not the account, and still
226
+ // escalates globally (meta.softError is ignored for 5xx — #1059).
227
+ meta: { softError: true },
228
+ })),
229
+ });
230
+
231
+ // Build accountId → unseen map for source counts
232
+ const unseenByAccount = useMemo<Map<string, number>>(() => {
233
+ const map = new Map<string, number>();
234
+ for (let i = 0; i < nonMuted.length; i++) {
235
+ const accountId = nonMuted[i].accountId;
236
+ const mailboxes = mailboxQueries[i]?.data?.items ?? [];
237
+ const total = mailboxes.reduce(
238
+ (sum, mb) => sum + (mb.unseenCount ?? 0),
239
+ 0,
240
+ );
241
+ map.set(accountId, total);
242
+ }
243
+ return map;
244
+ }, [nonMuted, mailboxQueries]);
245
+
246
+ // Per-account connection failures: accounts whose mailbox list failed for a
247
+ // reason that is genuinely the account's (e.g. IMAP down, auth expired) — a
248
+ // 4xx. A first-party 5xx is OUR API breaking, not the account being
249
+ // unreachable, so it must NOT render the misleading "can't connect /
250
+ // Reconnect" banner; the global escalation overlay (QueryCache.onError)
251
+ // handles it instead.
252
+ const failedAccounts = useMemo<RemitImapAccountResponse[]>(() => {
253
+ return nonMuted.filter((_, i) => {
254
+ const query = mailboxQueries[i];
255
+ if (!query?.isError) return false;
256
+ return !isServerError(query.error);
257
+ });
258
+ }, [nonMuted, mailboxQueries]);
259
+
260
+ const { freeText: sq, tokens: queryTokens } = parseSearchTokens(
261
+ searchQuery.trim().toLowerCase(),
262
+ { mailboxesByName: mailboxNameIndex, accountsByName: accountNameIndex },
263
+ );
264
+
265
+ // Convert API rows to ThreadRowData, narrowing only by the selected account
266
+ // and the free-text search plus any filter tokens (`from:`, `has:attachment`,
267
+ // `is:unread`, `before:`/`after:`, `in:`, `account:`) parsed out of the
268
+ // query. The category axis and the attribute chips are the kit
269
+ // `BriefSections` filter row's job, so the full per-category sections are
270
+ // handed to it; it groups, narrows, and flattens.
271
+ const filteredRows = useMemo<ThreadRowData[]>(() => {
272
+ const raw = threadsData?.items ?? [];
273
+ return raw
274
+ .filter(
275
+ (t) =>
276
+ selectedAccountId === "all" ||
277
+ (t.accountId ?? t.accountConfigId) === selectedAccountId,
278
+ )
279
+ .map(toThreadRowData)
280
+ .filter(
281
+ (t) =>
282
+ (!sq || matchesBriefSearch(t, sq)) &&
283
+ matchesSearchTokens(t, queryTokens),
284
+ );
285
+ }, [threadsData, selectedAccountId, sq, queryTokens]);
286
+
287
+ const sections = useMemo<ThreadSection[]>(
288
+ () => groupBriefSections(filteredRows),
289
+ [filteredRows],
290
+ );
291
+
292
+ const accountSources = useMemo<FilterSheetSource[]>(() => {
293
+ if (nonMuted.length <= 1) return [];
294
+ return [
295
+ { id: "all", label: "All", active: selectedAccountId === "all" },
296
+ ...nonMuted.map((account) => ({
297
+ id: account.accountId,
298
+ label: account.email.split("@")[0] ?? account.email,
299
+ count: unseenByAccount.get(account.accountId),
300
+ active: selectedAccountId === account.accountId,
301
+ })),
302
+ ];
303
+ }, [nonMuted, unseenByAccount, selectedAccountId]);
304
+
305
+ const mutedCount = useMemo(
306
+ () => accounts.filter((a) => a.muted?.value).length,
307
+ [accounts],
308
+ );
309
+
310
+ const totalUnseen = useMemo(
311
+ () => Array.from(unseenByAccount.values()).reduce((a, b) => a + b, 0),
312
+ [unseenByAccount],
313
+ );
314
+
315
+ // The phone search takeover renders the account/free-text-narrowed rows,
316
+ // further narrowed by the shared category and the takeover's attribute chips.
317
+ const searchResults = useMemo<SearchResult[]>(() => {
318
+ const predicates = Array.from(searchAttributes)
319
+ .map((id) => BRIEF_SEARCH_PREDICATES[id])
320
+ .filter((p): p is (t: ThreadRowData) => boolean => p != null);
321
+ return filteredRows
322
+ .filter(
323
+ (t) =>
324
+ (selectedCategory === "all" || t.category === selectedCategory) &&
325
+ predicates.every((p) => p(t)),
326
+ )
327
+ .map(rowToSearchResult);
328
+ }, [filteredRows, selectedCategory, searchAttributes]);
329
+
330
+ // "Related" (semantic) spans every account here — the brief is the
331
+ // cross-account view, so no mailbox scope. Dedupe against the literal "Top
332
+ // matches" by thread; the brief rows key on messageId, so resolve each back to
333
+ // its thread via the raw threads.
334
+ const { hits: semanticHits, isLoading: relatedLoading } = useSemanticSearch();
335
+ const relatedResults = useMemo<SearchResult[]>(() => {
336
+ const threadByMessageId = new Map<string, string>();
337
+ for (const thread of threadsData?.items ?? []) {
338
+ threadByMessageId.set(thread.messageId, thread.threadId);
339
+ }
340
+ const literalThreadIds = searchResults
341
+ .map((result) => threadByMessageId.get(result.id))
342
+ .filter((id): id is string => id != null);
343
+ return relatedSearchResults(semanticHits, literalThreadIds);
344
+ }, [semanticHits, searchResults, threadsData]);
345
+
346
+ const searchFilterConfig = useMemo<Omit<FilterSheetProps, "children">>(() => {
347
+ const preset = briefFilterConfig(
348
+ accountSources.map((s) => ({
349
+ id: s.id,
350
+ label: s.label,
351
+ count: s.count,
352
+ active: s.active,
353
+ })),
354
+ );
355
+ return {
356
+ categories: preset.categories,
357
+ filters: preset.filters,
358
+ sources: preset.sources,
359
+ sourcesNote: mutedCount > 0 ? `+${mutedCount} muted` : undefined,
360
+ selectedCategory,
361
+ activeFilters: searchAttributes,
362
+ expanded: searchExpanded,
363
+ onExpandedChange: setSearchExpanded,
364
+ onSelectCategory: (id: string) =>
365
+ setSelectedCategory(id as BriefCategoryFilter),
366
+ onSelectSource: setSelectedAccountId,
367
+ onToggleFilter: toggleSearchAttribute,
368
+ onClear: () => {
369
+ setSelectedCategory("all");
370
+ setSelectedAccountId("all");
371
+ setSearchAttributes(new Set());
372
+ },
373
+ };
374
+ }, [
375
+ accountSources,
376
+ mutedCount,
377
+ selectedCategory,
378
+ searchAttributes,
379
+ searchExpanded,
380
+ toggleSearchAttribute,
381
+ ]);
382
+
383
+ // The brief is genuinely empty (caught up) only when nothing is narrowing the
384
+ // view: no account source and no search. When a source/search yields nothing,
385
+ // the BriefSections filter row stays so the user can clear it.
386
+ const caughtUp =
387
+ sections.length === 0 && selectedAccountId === "all" && sq.length === 0;
388
+
389
+ const stateBody = isLoading ? (
390
+ <div className="h-full overflow-y-auto">
391
+ <SectionSkeleton />
392
+ <SectionSkeleton />
393
+ </div>
394
+ ) : isError ? (
395
+ <div className="flex h-full flex-col items-center justify-center gap-3 py-12 text-sm text-fg-muted">
396
+ <AlertCircle className="size-8 text-danger" />
397
+ <p>Couldn't load your messages</p>
398
+ <button
399
+ type="button"
400
+ onClick={() => refetch()}
401
+ className="flex items-center gap-1 text-accent underline text-xs"
402
+ >
403
+ <RefreshCw className="size-3.5" />
404
+ Try again
405
+ </button>
406
+ </div>
407
+ ) : caughtUp ? (
408
+ <div className="flex h-full flex-col items-center justify-center gap-2 py-12 text-center px-4">
409
+ <Sparkles className="size-8 text-fg-subtle" />
410
+ <p className="text-sm font-medium text-fg">You're caught up</p>
411
+ <p className="text-xs text-fg-subtle">Nothing needs attention.</p>
412
+ </div>
413
+ ) : (
414
+ <BriefSections
415
+ sections={sections}
416
+ Row={BriefRow}
417
+ briefCategory={selectedCategory}
418
+ onSelectBriefCategory={setSelectedCategory}
419
+ sources={accountSources}
420
+ sourcesNote={mutedCount > 0 ? `+${mutedCount} muted` : undefined}
421
+ onSelectSource={setSelectedAccountId}
422
+ selectedThreadId={selectedMessageId}
423
+ onSelectThread={onSelectMessage}
424
+ />
425
+ );
426
+
427
+ return (
428
+ <MailListHeader
429
+ title="Daily brief"
430
+ unreadCount={totalUnseen}
431
+ footer={isDesktop ? <KeyboardHintBar /> : undefined}
432
+ searchFilter={searchFilterConfig}
433
+ searchResults={searchResults}
434
+ searchLoading={isLoading}
435
+ relatedResults={relatedResults}
436
+ relatedLoading={relatedLoading}
437
+ onSelectSearchResult={onSelectSearchResult}
438
+ >
439
+ <div className="flex h-full flex-col">
440
+ {failedAccounts.map((account) => (
441
+ <ErrorBanner
442
+ key={account.accountId}
443
+ accountEmail={account.email}
444
+ accountId={account.accountId}
445
+ />
446
+ ))}
447
+ <div className="min-h-0 flex-1">{stateBody}</div>
448
+ </div>
449
+ </MailListHeader>
450
+ );
451
+ }