@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,466 @@
1
+ /**
2
+ * Senders & Rules — 2-pane page: rule groups left, dense filterable
3
+ * sender table right. Built to scale to hundreds of flagged senders.
4
+ *
5
+ * Backend gap (#427): there is no "list all addresses with flag X" endpoint.
6
+ * VIP group: served from GET /me/vip-suggestions (suggested-to-promote list).
7
+ * Muted / Blocked groups: users search by name/email to find flagged senders
8
+ * via GET /addresses/search; results are filtered client-side on the flag.
9
+ * The real flagged-address query endpoint is tracked as a backend issue.
10
+ */
11
+ import {
12
+ addressDetailOperationsUpdateAddressMutation,
13
+ addressOperationsSearchAddressesOptions,
14
+ meOperationsListVipSuggestionsOptions,
15
+ meOperationsListVipSuggestionsQueryKey,
16
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
17
+ import type {
18
+ RemitImapAddressResponse,
19
+ RemitImapVipSuggestionEntry,
20
+ } from "@remit/api-http-client/types.gen.ts";
21
+ import {
22
+ Button,
23
+ Input,
24
+ Kbd,
25
+ SenderFlagRow,
26
+ SenderGroupSwitch,
27
+ SettingsShell,
28
+ } from "@remit/ui";
29
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
30
+ import { createFileRoute, useNavigate } from "@tanstack/react-router";
31
+ import { Search, Star, X } from "lucide-react";
32
+ import { useCallback, useEffect, useState } from "react";
33
+ import { ErrorState } from "@/components/ui/ErrorState";
34
+ import { useDebouncedValue } from "@/hooks/useDebouncedValue";
35
+ import { formatDate } from "@/lib/format";
36
+ import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
37
+
38
+ export const Route = createFileRoute("/settings/senders")({
39
+ component: SendersSettings,
40
+ });
41
+
42
+ /* ------------------------------------------------------------------ */
43
+ /* Types */
44
+ /* ------------------------------------------------------------------ */
45
+
46
+ type SenderGroup = "vip" | "muted" | "blocked";
47
+
48
+ const groupLabels: Record<SenderGroup, string> = {
49
+ vip: "VIPs",
50
+ muted: "Muted",
51
+ blocked: "Blocked",
52
+ };
53
+
54
+ /* ------------------------------------------------------------------ */
55
+ /* Help rail copy — matches Storybook sendersHelp exactly */
56
+ /* ------------------------------------------------------------------ */
57
+
58
+ const sendersHelp = (
59
+ <div className="space-y-3">
60
+ <p>
61
+ <strong className="text-fg">VIP</strong> senders get priority surface in
62
+ the daily brief and notification escalation, even when an account is
63
+ quiet.
64
+ </p>
65
+ <p>
66
+ <strong className="text-fg">Muted</strong> senders never notify and stay
67
+ out of the brief. Their mail still syncs and stays searchable.
68
+ </p>
69
+ <p>
70
+ <strong className="text-fg">Blocked</strong> senders never load images and
71
+ go straight to junk.
72
+ </p>
73
+ <p>
74
+ Tip: flag from the keyboard on any message — <Kbd>v</Kbd> VIP,{" "}
75
+ <Kbd>m</Kbd> mute, <Kbd>b</Kbd> block. Every rule an automation creates
76
+ shows up here and can be removed.
77
+ </p>
78
+ </div>
79
+ );
80
+
81
+ /* ------------------------------------------------------------------ */
82
+ /* VIP group: served from suggestions endpoint */
83
+ /* ------------------------------------------------------------------ */
84
+
85
+ function VipGroupPane({
86
+ query,
87
+ onCount,
88
+ }: {
89
+ query: string;
90
+ onCount?: (matched: number, total: number) => void;
91
+ }) {
92
+ const { data, isPending, isError, error } = useQuery(
93
+ meOperationsListVipSuggestionsOptions(),
94
+ );
95
+ const queryClient = useQueryClient();
96
+
97
+ const addMutation = useMutation({
98
+ ...addressDetailOperationsUpdateAddressMutation(),
99
+ onSettled: () => {
100
+ queryClient.invalidateQueries({
101
+ queryKey: meOperationsListVipSuggestionsQueryKey(),
102
+ });
103
+ },
104
+ });
105
+
106
+ const suggestions = data?.suggestions ?? [];
107
+ const q = query.trim().toLowerCase();
108
+ const filtered = q
109
+ ? suggestions.filter(
110
+ (s) =>
111
+ (s.displayName ?? "").toLowerCase().includes(q) ||
112
+ s.normalizedEmail.toLowerCase().includes(q),
113
+ )
114
+ : suggestions;
115
+
116
+ useEffect(() => {
117
+ onCount?.(filtered.length, suggestions.length);
118
+ }, [onCount, filtered.length, suggestions.length]);
119
+
120
+ if (isPending) {
121
+ return (
122
+ <div className="px-row-inset py-3 text-sm text-fg-subtle animate-pulse">
123
+ Loading VIP suggestions…
124
+ </div>
125
+ );
126
+ }
127
+
128
+ if (isError) {
129
+ return (
130
+ <div className="px-row-inset py-3">
131
+ <ErrorState
132
+ variant="inline"
133
+ title="Couldn't load VIP suggestions"
134
+ error={error}
135
+ />
136
+ </div>
137
+ );
138
+ }
139
+
140
+ if (suggestions.length === 0) {
141
+ return (
142
+ <div className="px-row-inset py-5 text-sm text-fg-subtle">
143
+ No VIP suggestions yet. As you reply to senders, they&apos;ll appear
144
+ here. Press <Kbd>v</Kbd> on any message to promote a sender to VIP.
145
+ </div>
146
+ );
147
+ }
148
+
149
+ if (filtered.length === 0) {
150
+ return (
151
+ <p className="px-row-inset py-5 text-sm text-fg-subtle">
152
+ No VIPs match &ldquo;{query}&rdquo;.
153
+ </p>
154
+ );
155
+ }
156
+
157
+ return (
158
+ <>
159
+ <div className="px-row-inset py-1 text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
160
+ Suggested — promote to VIP
161
+ </div>
162
+ {filtered.map((s: RemitImapVipSuggestionEntry) => (
163
+ <SenderFlagRow
164
+ key={s.addressId}
165
+ name={s.displayName ?? s.normalizedEmail}
166
+ email={s.normalizedEmail}
167
+ meta={
168
+ s.replyCount > 0
169
+ ? `${s.inboundCount} received · you replied ${s.replyCount}×`
170
+ : `${s.inboundCount} received · you've never replied`
171
+ }
172
+ trailing={
173
+ <Button
174
+ variant="ghost"
175
+ size="sm"
176
+ icon={<Star className="size-3.5" />}
177
+ aria-busy={addMutation.isPending}
178
+ onClick={() => {
179
+ if (addMutation.isPending) return;
180
+ addMutation.mutate({
181
+ path: { addressId: s.addressId },
182
+ body: {
183
+ flags: {
184
+ vip: { value: true, setAt: Date.now(), setBy: "user" },
185
+ },
186
+ },
187
+ });
188
+ }}
189
+ >
190
+ Add VIP
191
+ </Button>
192
+ }
193
+ />
194
+ ))}
195
+ </>
196
+ );
197
+ }
198
+
199
+ /* ------------------------------------------------------------------ */
200
+ /* Muted / Blocked groups: search-based (backend gap noted) */
201
+ /* ------------------------------------------------------------------ */
202
+
203
+ function SearchGroupPane({
204
+ group,
205
+ query,
206
+ onCount,
207
+ }: {
208
+ group: "muted" | "blocked";
209
+ query: string;
210
+ onCount?: (matched: number, total: number | null) => void;
211
+ }) {
212
+ const debouncedQ = useDebouncedValue(query.trim(), 300);
213
+ const queryClient = useQueryClient();
214
+ const [retryId, setRetryId] = useState<string | null>(null);
215
+
216
+ const enabled = debouncedQ.length >= 2;
217
+
218
+ const searchOptions = addressOperationsSearchAddressesOptions({
219
+ query: { q: debouncedQ, limit: 50 },
220
+ });
221
+
222
+ const { data, isPending, isError, error } = useQuery({
223
+ ...searchOptions,
224
+ enabled,
225
+ });
226
+
227
+ const removeMutation = useMutation({
228
+ ...addressDetailOperationsUpdateAddressMutation(),
229
+ onSuccess: () => {
230
+ queryClient.invalidateQueries({ queryKey: searchOptions.queryKey });
231
+ },
232
+ });
233
+
234
+ const items = (data?.items ?? []).filter((a: RemitImapAddressResponse) =>
235
+ group === "muted"
236
+ ? (a.flags?.muted?.value ?? false)
237
+ : (a.flags?.blocked?.value ?? false),
238
+ );
239
+
240
+ useEffect(() => {
241
+ // Total flagged count is unknown (no list-all-flagged endpoint), so the
242
+ // group rail shows the live matched-row count while a search is active.
243
+ onCount?.(enabled ? items.length : 0, null);
244
+ }, [onCount, enabled, items.length]);
245
+
246
+ if (!enabled) {
247
+ // Backend gap: no list-all-flagged endpoint; prompt user to search.
248
+ return (
249
+ <div className="px-row-inset py-5 text-sm text-fg-subtle space-y-2">
250
+ <p>
251
+ Type 2+ characters to search {groupLabels[group].toLowerCase()}{" "}
252
+ senders by name or address.
253
+ </p>
254
+ <p className="text-2xs text-fg-subtle">
255
+ Note: a dedicated flagged-address list endpoint is in the backlog.
256
+ Press <Kbd>m</Kbd> on any message to mute its sender; <Kbd>b</Kbd> to
257
+ block.
258
+ </p>
259
+ </div>
260
+ );
261
+ }
262
+
263
+ if (isPending) {
264
+ return (
265
+ <div className="px-row-inset py-3 text-sm text-fg-subtle animate-pulse">
266
+ Searching…
267
+ </div>
268
+ );
269
+ }
270
+
271
+ if (isError) {
272
+ return (
273
+ <div className="px-row-inset py-3">
274
+ <ErrorState variant="inline" title="Search failed" error={error} />
275
+ </div>
276
+ );
277
+ }
278
+
279
+ if (items.length === 0) {
280
+ return (
281
+ <p className="px-row-inset py-5 text-sm text-fg-subtle">
282
+ No {groupLabels[group].toLowerCase()} senders matching &ldquo;
283
+ {debouncedQ}&rdquo;.
284
+ </p>
285
+ );
286
+ }
287
+
288
+ return (
289
+ <>
290
+ {items.map((a: RemitImapAddressResponse) => {
291
+ const flag = group === "muted" ? a.flags?.muted : a.flags?.blocked;
292
+ // The search response carries no engagement counts, so the row's
293
+ // info line is the flag provenance (when/why it was flagged)
294
+ // rather than echoing the email already in the header.
295
+ const meta = flag
296
+ ? [
297
+ `${group}`,
298
+ flag.setAt
299
+ ? formatDate(flag.setAt, {
300
+ month: "short",
301
+ year: "numeric",
302
+ }) || null
303
+ : null,
304
+ flag.reason ? `— ${flag.reason}` : null,
305
+ ]
306
+ .filter(Boolean)
307
+ .join(" ")
308
+ : group;
309
+
310
+ const isPendingRemove =
311
+ removeMutation.isPending &&
312
+ removeMutation.variables?.path.addressId === a.addressId;
313
+ const isFailedRemove =
314
+ removeMutation.isError && retryId === a.addressId;
315
+
316
+ return (
317
+ <SenderFlagRow
318
+ key={a.addressId}
319
+ name={a.displayName ?? a.normalizedEmail}
320
+ email={a.normalizedEmail}
321
+ danger={group === "blocked"}
322
+ meta={meta}
323
+ trailing={
324
+ isFailedRemove ? (
325
+ <Button
326
+ variant="secondary"
327
+ size="sm"
328
+ onClick={() => {
329
+ setRetryId(a.addressId);
330
+ removeMutation.mutate({
331
+ path: { addressId: a.addressId },
332
+ body: { flags: { [group]: null } },
333
+ });
334
+ }}
335
+ >
336
+ Retry
337
+ </Button>
338
+ ) : (
339
+ <Button
340
+ variant="ghost"
341
+ size="sm"
342
+ icon={<X className="size-3.5" />}
343
+ aria-busy={isPendingRemove}
344
+ aria-label={`Remove ${group} flag`}
345
+ onClick={() => {
346
+ if (isPendingRemove) return;
347
+ setRetryId(a.addressId);
348
+ removeMutation.mutate({
349
+ path: { addressId: a.addressId },
350
+ body: { flags: { [group]: null } },
351
+ });
352
+ }}
353
+ />
354
+ )
355
+ }
356
+ />
357
+ );
358
+ })}
359
+ </>
360
+ );
361
+ }
362
+
363
+ /* ------------------------------------------------------------------ */
364
+ /* Page component */
365
+ /* ------------------------------------------------------------------ */
366
+
367
+ function SendersSettings() {
368
+ const navigate = useNavigate();
369
+ const [group, setGroup] = useState<SenderGroup>("vip");
370
+ const [query, setQuery] = useState("");
371
+ const [helpOpen, setHelpOpen] = useState(true);
372
+
373
+ // Per-group counts for the rail + footer. VIP total is the suggestions
374
+ // list size; muted/blocked have no list endpoint, so their total is null
375
+ // (the rail shows the live matched-row count instead of a stale total).
376
+ const [matched, setMatched] = useState(0);
377
+ const [groupCounts, setGroupCounts] = useState<
378
+ Record<SenderGroup, number | null>
379
+ >({ vip: 0, muted: null, blocked: null });
380
+
381
+ const handleVipCount = useCallback((m: number, total: number) => {
382
+ setMatched(m);
383
+ setGroupCounts((prev) => ({ ...prev, vip: total }));
384
+ }, []);
385
+
386
+ const handleSearchCount = useCallback(
387
+ (m: number, _total: number | null) => {
388
+ setMatched(m);
389
+ setGroupCounts((prev) => ({ ...prev, [group]: m }));
390
+ },
391
+ [group],
392
+ );
393
+
394
+ const total = groupCounts[group];
395
+
396
+ const handleSelectNav = (id: string) => {
397
+ const path = SETTINGS_ID_TO_PATH[id];
398
+ if (path) void navigate({ to: path });
399
+ };
400
+
401
+ return (
402
+ <SettingsShell
403
+ items={SETTINGS_NAV_ITEMS}
404
+ activeId="senders"
405
+ title="Senders & Rules"
406
+ description="Per-sender preferences — set with one key from any message, managed here."
407
+ flush
408
+ help={sendersHelp}
409
+ helpOpen={helpOpen}
410
+ onToggleHelp={() => setHelpOpen((v) => !v)}
411
+ onSelect={handleSelectNav}
412
+ onBackToMail={() => void navigate({ to: "/mail" })}
413
+ >
414
+ <div className="flex min-h-0 flex-1 flex-col lg:flex-row">
415
+ <SenderGroupSwitch<SenderGroup>
416
+ active={group}
417
+ onSelect={(g) => {
418
+ setGroup(g);
419
+ setQuery("");
420
+ }}
421
+ options={(["vip", "muted", "blocked"] as SenderGroup[]).map((g) => ({
422
+ id: g,
423
+ label: groupLabels[g],
424
+ count: groupCounts[g],
425
+ }))}
426
+ />
427
+
428
+ {/* Dense filterable sender table */}
429
+ <div className="flex min-w-0 flex-1 flex-col">
430
+ <div className="border-b border-line px-row-inset py-2">
431
+ <Input
432
+ icon={<Search className="size-4" />}
433
+ placeholder={`Filter ${groupLabels[group].toLowerCase()} by name or address`}
434
+ className="h-8 max-w-sm"
435
+ value={query}
436
+ onChange={(e) => setQuery(e.target.value)}
437
+ />
438
+ </div>
439
+
440
+ <div className="min-h-0 flex-1 divide-y divide-line overflow-y-auto">
441
+ {group === "vip" ? (
442
+ <VipGroupPane query={query} onCount={handleVipCount} />
443
+ ) : (
444
+ <SearchGroupPane
445
+ group={group}
446
+ query={query}
447
+ onCount={handleSearchCount}
448
+ />
449
+ )}
450
+ </div>
451
+
452
+ <footer className="flex items-center justify-between border-t border-line px-row-inset py-1 text-2xs text-fg-subtle">
453
+ <span>
454
+ {total != null
455
+ ? `${matched} of ${total} flagged senders`
456
+ : `${matched} flagged senders`}
457
+ </span>
458
+ <span>
459
+ <Kbd>j</Kbd> <Kbd>k</Kbd> navigate · <Kbd>⌫</Kbd> remove flag
460
+ </span>
461
+ </footer>
462
+ </div>
463
+ </div>
464
+ </SettingsShell>
465
+ );
466
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Suggested VIPs — now folded into Senders & Rules (VIP group).
3
+ * This route redirects to /settings/senders so existing deep links
4
+ * and smoke test paths continue to resolve gracefully.
5
+ */
6
+ import { createFileRoute, Navigate } from "@tanstack/react-router";
7
+
8
+ export const Route = createFileRoute("/settings/suggested-vips")({
9
+ component: () => <Navigate to="/settings/senders" replace />,
10
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Settings layout route — a pass-through Outlet. Each child page
3
+ * (`/settings/accounts`, `/settings/senders`, …) renders its own
4
+ * full-viewport `SettingsShell` from @remit/ui, exactly as the
5
+ * approved Storybook stories do. This route only provides the route
6
+ * boundary; no layout chrome lives here.
7
+ *
8
+ * Shared nav items and helpers are exported so every child page imports
9
+ * them from this file, keeping a single source of truth for nav shape.
10
+ */
11
+ import type { SettingsNavItem } from "@remit/ui";
12
+ import { createFileRoute, Outlet } from "@tanstack/react-router";
13
+ import {
14
+ Filter,
15
+ FolderTree,
16
+ Inbox,
17
+ Palette,
18
+ Users,
19
+ Wrench,
20
+ } from "lucide-react";
21
+
22
+ /* ------------------------------------------------------------------ */
23
+ /* Nav items — single source of truth shared by all settings pages */
24
+ /* ------------------------------------------------------------------ */
25
+
26
+ export const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
27
+ { id: "accounts", label: "Accounts", icon: <Inbox className="size-4" /> },
28
+ {
29
+ id: "senders",
30
+ label: "Senders & Rules",
31
+ icon: <Users className="size-4" />,
32
+ },
33
+ {
34
+ id: "folders",
35
+ label: "Folder roles",
36
+ icon: <FolderTree className="size-4" />,
37
+ },
38
+ {
39
+ id: "filters",
40
+ label: "Filters",
41
+ icon: <Filter className="size-4" />,
42
+ },
43
+ {
44
+ id: "appearance",
45
+ label: "Appearance",
46
+ icon: <Palette className="size-4" />,
47
+ },
48
+ { id: "advanced", label: "Advanced", icon: <Wrench className="size-4" /> },
49
+ ];
50
+
51
+ export const SETTINGS_ID_TO_PATH: Record<string, string> = {
52
+ accounts: "/settings/accounts",
53
+ senders: "/settings/senders",
54
+ folders: "/settings/folders",
55
+ filters: "/settings/filters",
56
+ appearance: "/settings/appearance",
57
+ advanced: "/settings/advanced",
58
+ };
59
+
60
+ /* ------------------------------------------------------------------ */
61
+ /* Route — pass-through */
62
+ /* ------------------------------------------------------------------ */
63
+
64
+ export const Route = createFileRoute("/settings")({
65
+ component: () => <Outlet />,
66
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Deploy-specific configuration, read at runtime from `window.__REMIT_CONFIG__`
3
+ * (set by `/config.js`, loaded before the app bundle). One built artifact serves
4
+ * every deployment: the self-host image ships a default `config.js`, the AWS
5
+ * deploy writes one with Cognito values, and the dev server injects one from
6
+ * `REMIT_RUNTIME_CONFIG`. Nothing here is baked at build time.
7
+ */
8
+
9
+ export interface CognitoConfig {
10
+ userPoolId: string;
11
+ clientId: string;
12
+ domain: string;
13
+ region: string;
14
+ }
15
+
16
+ export interface RumConfig {
17
+ appMonitorId: string;
18
+ identityPoolId: string;
19
+ region: string;
20
+ }
21
+
22
+ export interface RemitRuntimeConfig {
23
+ apiUrl: string;
24
+ appOrigin: string;
25
+ betterAuthEnabled: boolean;
26
+ cognito: CognitoConfig;
27
+ rum: RumConfig;
28
+ mailboxPollIntervalSeconds?: string;
29
+ disableDevtools: boolean;
30
+ }
31
+
32
+ interface RawRuntimeConfig {
33
+ apiUrl?: string;
34
+ appOrigin?: string;
35
+ betterAuthEnabled?: boolean;
36
+ cognito?: Partial<CognitoConfig>;
37
+ rum?: Partial<RumConfig>;
38
+ mailboxPollIntervalSeconds?: string | number;
39
+ disableDevtools?: boolean;
40
+ }
41
+
42
+ declare global {
43
+ // eslint-disable-next-line no-var
44
+ var __REMIT_CONFIG__: RawRuntimeConfig | undefined;
45
+ }
46
+
47
+ /**
48
+ * Read `window.__REMIT_CONFIG__` fresh on each call and normalize it. Reading at
49
+ * call time (not module load) keeps it re-evaluable: the browser sets the global
50
+ * once before boot, and tests swap it between cases.
51
+ */
52
+ export const getRuntimeConfig = (): RemitRuntimeConfig => {
53
+ const raw: RawRuntimeConfig = globalThis.__REMIT_CONFIG__ ?? {};
54
+ return {
55
+ apiUrl: raw.apiUrl ?? "/api",
56
+ appOrigin: raw.appOrigin ?? "",
57
+ betterAuthEnabled: raw.betterAuthEnabled ?? false,
58
+ cognito: {
59
+ userPoolId: raw.cognito?.userPoolId ?? "",
60
+ clientId: raw.cognito?.clientId ?? "",
61
+ domain: raw.cognito?.domain ?? "",
62
+ region: raw.cognito?.region ?? "",
63
+ },
64
+ rum: {
65
+ appMonitorId: raw.rum?.appMonitorId ?? "",
66
+ identityPoolId: raw.rum?.identityPoolId ?? "",
67
+ region: raw.rum?.region ?? "eu-west-1",
68
+ },
69
+ mailboxPollIntervalSeconds:
70
+ raw.mailboxPollIntervalSeconds != null
71
+ ? String(raw.mailboxPollIntervalSeconds)
72
+ : undefined,
73
+ disableDevtools: raw.disableDevtools ?? false,
74
+ };
75
+ };