@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,111 @@
1
+ export type ErrorBannerSeverity = "error" | "warning" | "info";
2
+
3
+ export interface ErrorBannerEntry {
4
+ id: string;
5
+ severity: ErrorBannerSeverity;
6
+ title: string;
7
+ detail?: string;
8
+ createdAt: number;
9
+ }
10
+
11
+ export interface PushErrorInput {
12
+ severity?: ErrorBannerSeverity;
13
+ title: string;
14
+ detail?: string;
15
+ }
16
+
17
+ const MAX_BANNERS = 5;
18
+
19
+ /**
20
+ * Build the banner input for a mutation failure: the error's own message when
21
+ * it has one, otherwise a human fallback. Shared by the previously-silent
22
+ * mutation `onError` handlers (signature save, outbox retry/delete, draft
23
+ * discard) so they surface a real detail instead of nothing. A fatal 5xx is
24
+ * additionally escalated globally; this only governs the soft banner copy.
25
+ */
26
+ export const buildMutationErrorBanner = (
27
+ title: string,
28
+ fallback: string,
29
+ error: unknown,
30
+ ): PushErrorInput => ({
31
+ title,
32
+ detail: formatErrorDetail(error) ?? fallback,
33
+ });
34
+
35
+ export const formatErrorDetail = (error: unknown): string | undefined => {
36
+ if (error === undefined || error === null) return undefined;
37
+ if (error instanceof Error) return error.message || undefined;
38
+ if (typeof error === "string") return error || undefined;
39
+ if (
40
+ typeof error === "object" &&
41
+ "message" in error &&
42
+ typeof (error as { message: unknown }).message === "string"
43
+ ) {
44
+ const message = (error as { message: string }).message;
45
+ return message || undefined;
46
+ }
47
+ return undefined;
48
+ };
49
+
50
+ /**
51
+ * Detect a "describeMessage hit a deleted/missing row" error.
52
+ *
53
+ * Soft-deleted ThreadMessage rows used to leak into the inbox list (#212);
54
+ * clicking one fired `describeMessage` against an already-hard-deleted
55
+ * Message and the backend's `NotFoundError` ("Message not found: <id>")
56
+ * surfaced verbatim. We now filter those rows server-side, but the UI still
57
+ * needs a graceful empty state when the cache is mid-refresh or the row
58
+ * was just deleted in another tab.
59
+ *
60
+ * The backend serializes 404s as `{ message: "Message not found: <id>" }`
61
+ * (see `packages/backend/src/error.ts`). The hey-api fetch client
62
+ * throws the parsed JSON body as the error, so we match by message prefix.
63
+ */
64
+ export const isMessageNotFoundError = (error: unknown): boolean => {
65
+ const detail = formatErrorDetail(error);
66
+ if (!detail) return false;
67
+ return detail.startsWith("Message not found");
68
+ };
69
+
70
+ const sameInput = (
71
+ entry: ErrorBannerEntry,
72
+ input: PushErrorInput,
73
+ severity: ErrorBannerSeverity,
74
+ ): boolean =>
75
+ entry.severity === severity &&
76
+ entry.title === input.title &&
77
+ entry.detail === input.detail;
78
+
79
+ export const appendBanner = (
80
+ current: ErrorBannerEntry[],
81
+ entry: ErrorBannerEntry,
82
+ ): ErrorBannerEntry[] => {
83
+ const filtered = current.filter(
84
+ (existing) =>
85
+ !sameInput(
86
+ existing,
87
+ { title: entry.title, detail: entry.detail, severity: entry.severity },
88
+ entry.severity,
89
+ ),
90
+ );
91
+ const next = [...filtered, entry];
92
+ if (next.length <= MAX_BANNERS) return next;
93
+ return next.slice(next.length - MAX_BANNERS);
94
+ };
95
+
96
+ export const dismissBanner = (
97
+ current: ErrorBannerEntry[],
98
+ id: string,
99
+ ): ErrorBannerEntry[] => current.filter((entry) => entry.id !== id);
100
+
101
+ export const buildEntry = (
102
+ input: PushErrorInput,
103
+ id: string,
104
+ now: number,
105
+ ): ErrorBannerEntry => ({
106
+ id,
107
+ severity: input.severity ?? "error",
108
+ title: input.title,
109
+ detail: input.detail,
110
+ createdAt: now,
111
+ });
@@ -0,0 +1,38 @@
1
+ export const threadKeys = {
2
+ all: ["threads"] as const,
3
+
4
+ lists: () => [...threadKeys.all, "list"] as const,
5
+
6
+ list: (
7
+ mailboxId: string,
8
+ filters?: { filter?: string; page?: number; cursor?: string },
9
+ ) => [...threadKeys.lists(), mailboxId, filters] as const,
10
+
11
+ details: () => [...threadKeys.all, "detail"] as const,
12
+
13
+ detail: (threadId: string) => [...threadKeys.details(), threadId] as const,
14
+
15
+ messages: (threadId: string) =>
16
+ [...threadKeys.detail(threadId), "messages"] as const,
17
+ };
18
+
19
+ export const mailboxKeys = {
20
+ all: ["mailboxes"] as const,
21
+
22
+ lists: () => [...mailboxKeys.all, "list"] as const,
23
+
24
+ list: (accountId: string) => [...mailboxKeys.lists(), accountId] as const,
25
+
26
+ detail: (mailboxId: string) =>
27
+ [...mailboxKeys.all, "detail", mailboxId] as const,
28
+ };
29
+
30
+ export const messageKeys = {
31
+ all: ["messages"] as const,
32
+
33
+ detail: (messageId: string) =>
34
+ [...messageKeys.all, "detail", messageId] as const,
35
+
36
+ body: (messageId: string) =>
37
+ [...messageKeys.detail(messageId), "body"] as const,
38
+ };
@@ -0,0 +1,104 @@
1
+ import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type {
3
+ RemitImapCanonicalMailboxRole,
4
+ RemitImapFolderAppointment,
5
+ } from "@remit/api-http-client/types.gen.ts";
6
+ import { useQuery } from "@tanstack/react-query";
7
+ import { useMemo } from "react";
8
+
9
+ /**
10
+ * RFC 032 exclusive-folder-appointment (#976): every "which mailbox plays
11
+ * role X" question resolves from the account's `folderAppointments` map, not
12
+ * from independently re-detecting SPECIAL-USE flags or hardcoded name lists.
13
+ * The map is server-resolved (persisted user choice, or a proposal for
14
+ * anything unfilled), so a single shared lookup replaces what used to be four
15
+ * near-identical flag-then-name detectors.
16
+ */
17
+ const useFolderRoleMailbox = (
18
+ accountId: string | undefined,
19
+ role: RemitImapCanonicalMailboxRole,
20
+ ): { mailboxId: string | undefined; isLoading: boolean } => {
21
+ const { data: config, isLoading } = useQuery({
22
+ ...configOperationsGetConfigOptions(),
23
+ staleTime: Infinity,
24
+ enabled: !!accountId,
25
+ });
26
+
27
+ const mailboxId = useMemo(() => {
28
+ if (!accountId) return undefined;
29
+ const account = config?.accounts.find((a) => a.accountId === accountId);
30
+ return account?.folderAppointments.find((fa) => fa.role === role)
31
+ ?.mailboxId;
32
+ }, [config, accountId, role]);
33
+
34
+ return { mailboxId, isLoading };
35
+ };
36
+
37
+ /**
38
+ * Returns the account's appointed Archive mailbox id. `undefined` while
39
+ * loading or when no folder is appointed to (or proposed for) the role.
40
+ */
41
+ export const useArchiveMailbox = (
42
+ accountId: string | undefined,
43
+ ): { archiveMailboxId: string | undefined; isLoading: boolean } => {
44
+ const { mailboxId, isLoading } = useFolderRoleMailbox(accountId, "Archive");
45
+ return { archiveMailboxId: mailboxId, isLoading };
46
+ };
47
+
48
+ /**
49
+ * Returns the account's appointed Drafts mailbox id. Used by the mailbox
50
+ * route to decide whether to render the segmented Drafts view (Remit drafts +
51
+ * IMAP Drafts) in place of the flat message list (issue #505).
52
+ */
53
+ export const useDraftsMailbox = (
54
+ accountId: string | undefined,
55
+ ): { draftsMailboxId: string | undefined; isLoading: boolean } => {
56
+ const { mailboxId, isLoading } = useFolderRoleMailbox(accountId, "Drafts");
57
+ return { draftsMailboxId: mailboxId, isLoading };
58
+ };
59
+
60
+ /**
61
+ * Returns the account's Inbox mailbox id — the destination for "Not spam"
62
+ * (issue #594), which moves a message out of Junk back to the inbox.
63
+ */
64
+ export const useInboxMailbox = (
65
+ accountId: string | undefined,
66
+ ): { inboxMailboxId: string | undefined; isLoading: boolean } => {
67
+ const { mailboxId, isLoading } = useFolderRoleMailbox(accountId, "Inbox");
68
+ return { inboxMailboxId: mailboxId, isLoading };
69
+ };
70
+
71
+ /**
72
+ * Returns the account's appointed Junk/Spam mailbox id, used by the triage
73
+ * `!` (mark junk) key (#429). The message-flags API has no `$Junk` keyword
74
+ * field, so "mark junk" is realized as a move to the Junk mailbox.
75
+ */
76
+ export const useJunkMailbox = (
77
+ accountId: string | undefined,
78
+ ): { junkMailboxId: string | undefined; isLoading: boolean } => {
79
+ const { mailboxId, isLoading } = useFolderRoleMailbox(accountId, "Junk");
80
+ return { junkMailboxId: mailboxId, isLoading };
81
+ };
82
+
83
+ /**
84
+ * Returns an account's full role→mailbox appointment map, for callers that
85
+ * need more than one role at once (e.g. the Move-to picker excluding both
86
+ * Drafts and Sent, and ordering system folders by role). Empty while loading
87
+ * or when `accountId` is undefined.
88
+ */
89
+ export const useFolderAppointments = (
90
+ accountId: string | undefined,
91
+ ): readonly RemitImapFolderAppointment[] => {
92
+ const { data: config } = useQuery({
93
+ ...configOperationsGetConfigOptions(),
94
+ staleTime: Infinity,
95
+ enabled: !!accountId,
96
+ });
97
+
98
+ return useMemo(
99
+ () =>
100
+ config?.accounts.find((a) => a.accountId === accountId)
101
+ ?.folderAppointments ?? [],
102
+ [config, accountId],
103
+ );
104
+ };
@@ -0,0 +1,77 @@
1
+ import type { RemitImapAutoMovedInfo } from "@remit/api-http-client/types.gen.ts";
2
+ import { useCallback } from "react";
3
+ import {
4
+ autoMovedLabel,
5
+ isAutoMoveInEffect,
6
+ resolveUndoTargetMailboxId,
7
+ } from "@/lib/auto-moved";
8
+ import { useInboxMailbox, useJunkMailbox } from "./useArchiveMailbox";
9
+ import { useMoveMessages } from "./useMoveMessages";
10
+
11
+ interface UseAutoMovedBadgeOptions {
12
+ accountId: string | undefined;
13
+ messageId: string;
14
+ threadId: string;
15
+ /** The message's current mailbox — the row/card it's rendered in. */
16
+ mailboxId: string;
17
+ autoMoved: RemitImapAutoMovedInfo | undefined;
18
+ }
19
+
20
+ export interface AutoMovedBadgeState {
21
+ /** Whether the move is still in effect and the badge should render. */
22
+ show: boolean;
23
+ label: string;
24
+ /** Present only when the undo target mailbox resolved. */
25
+ onUndo?: () => void;
26
+ isUndoing: boolean;
27
+ }
28
+
29
+ /**
30
+ * Composes the account's Inbox/Junk mailboxes with the message's `autoMoved`
31
+ * projection into everything the `AutoMovedBadge` kit component needs: the
32
+ * derived "still in effect" gate, the plain-language label, and a one-click
33
+ * undo bound to the existing `moveMessages` mutation (no new endpoint — moves
34
+ * back through the same bulk move operation, the other direction).
35
+ *
36
+ * `show` re-derives on every render from `mailboxId` — no local dismissed
37
+ * flag. Once `moveMessages` settles, its query invalidation refetches the
38
+ * thread row with its updated `mailboxId`, and the badge naturally stops
39
+ * showing (doc/rules/data-flow.md).
40
+ */
41
+ export const useAutoMovedBadge = ({
42
+ accountId,
43
+ messageId,
44
+ threadId,
45
+ mailboxId,
46
+ autoMoved,
47
+ }: UseAutoMovedBadgeOptions): AutoMovedBadgeState => {
48
+ const { inboxMailboxId } = useInboxMailbox(accountId);
49
+ const { junkMailboxId } = useJunkMailbox(accountId);
50
+ const { moveMessages, isPending } = useMoveMessages({
51
+ mailboxId,
52
+ threadId,
53
+ accountId,
54
+ });
55
+
56
+ const roleMailboxes = { inboxMailboxId, junkMailboxId };
57
+ const show = isAutoMoveInEffect(autoMoved, mailboxId, roleMailboxes);
58
+ const undoTargetMailboxId = autoMoved
59
+ ? resolveUndoTargetMailboxId(autoMoved.fromPlacement, roleMailboxes)
60
+ : undefined;
61
+
62
+ const handleUndo = useCallback(() => {
63
+ if (!undoTargetMailboxId) return;
64
+ moveMessages([messageId], undoTargetMailboxId);
65
+ }, [moveMessages, messageId, undoTargetMailboxId]);
66
+
67
+ if (!show || !autoMoved) {
68
+ return { show: false, label: "", isUndoing: false };
69
+ }
70
+
71
+ return {
72
+ show: true,
73
+ label: autoMovedLabel(autoMoved.fromPlacement),
74
+ onUndo: undoTargetMailboxId ? handleUndo : undefined,
75
+ isUndoing: isPending,
76
+ };
77
+ };
@@ -0,0 +1,99 @@
1
+ import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
3
+ import { useQueries } from "@tanstack/react-query";
4
+ import { useLocation, useParams } from "@tanstack/react-router";
5
+ import { useCallback } from "react";
6
+ import { useTranslation } from "react-i18next";
7
+ import { buildMailboxRoleMap, labelForMailbox } from "@/lib/folder-roles";
8
+
9
+ interface UseCurrentMailboxNameOptions {
10
+ accounts: RemitImapAccountResponse[];
11
+ }
12
+
13
+ /**
14
+ * Resolves a display label for the mailbox or pseudo-mailbox the user is
15
+ * currently looking at — used by the mobile top bar so it reads e.g.
16
+ * "Inbox" or "Outbox" instead of the static "Remit" branding.
17
+ *
18
+ * Priority:
19
+ * - `/mail/outbox` → "Outbox"
20
+ * - `/mail/$mailboxId` → display name from the mailbox query
21
+ * - everything else (e.g. `/mail` mid-redirect) → `null`
22
+ *
23
+ * Reuses the same `mailboxOperationsListMailboxesOptions` queries the
24
+ * `MailSidebarAdapter` runs, so on warm cache (the sidebar is always
25
+ * mounted on `/mail/*`) this is a free lookup.
26
+ */
27
+ export const useCurrentMailboxName = ({
28
+ accounts,
29
+ }: UseCurrentMailboxNameOptions): string | null => {
30
+ const params = useParams({ strict: false });
31
+ const location = useLocation();
32
+ const mailboxId = (params as { mailboxId?: string }).mailboxId;
33
+ const { t } = useTranslation("mail", { useSuspense: false });
34
+ const translator = useCallback(
35
+ (key: string, fallback: string) => t(key, { defaultValue: fallback }),
36
+ [t],
37
+ );
38
+
39
+ const queries = useQueries({
40
+ queries: accounts.map((account) => ({
41
+ ...mailboxOperationsListMailboxesOptions({
42
+ path: { accountId: account.accountId },
43
+ }),
44
+ staleTime: Infinity,
45
+ })),
46
+ });
47
+
48
+ if (location.pathname.startsWith("/mail/outbox")) return "Outbox";
49
+ if (!mailboxId) return null;
50
+
51
+ for (let i = 0; i < accounts.length; i++) {
52
+ const items = queries[i]?.data?.items;
53
+ if (!items) continue;
54
+ const match = items.find((mailbox) => mailbox.mailboxId === mailboxId);
55
+ if (!match) continue;
56
+ const roleMap = buildMailboxRoleMap(accounts[i].folderAppointments);
57
+ return labelForMailbox(match, roleMap.get(mailboxId), translator);
58
+ }
59
+
60
+ return null;
61
+ };
62
+
63
+ /**
64
+ * Resolves the authoritative unread (unseen) total for the current mailbox
65
+ * from the mailbox query — the same warm-cache `mailboxOperationsListMailboxes`
66
+ * data the sidebar and `useCurrentMailboxName` already read.
67
+ *
68
+ * Returns `mailbox.unseenCount` (the true server-side total), not a count
69
+ * derived from the loaded infinite-query pages, which would undercount on any
70
+ * mailbox larger than one page and creep upward as the user scrolls. Returns
71
+ * `null` when the mailbox isn't resolvable yet (loading, or no `mailboxId`),
72
+ * letting callers fall back to a loaded-derived count.
73
+ */
74
+ export const useCurrentMailboxUnseenCount = ({
75
+ accounts,
76
+ }: UseCurrentMailboxNameOptions): number | null => {
77
+ const params = useParams({ strict: false });
78
+ const mailboxId = (params as { mailboxId?: string }).mailboxId;
79
+
80
+ const queries = useQueries({
81
+ queries: accounts.map((account) => ({
82
+ ...mailboxOperationsListMailboxesOptions({
83
+ path: { accountId: account.accountId },
84
+ }),
85
+ staleTime: Infinity,
86
+ })),
87
+ });
88
+
89
+ if (!mailboxId) return null;
90
+
91
+ for (const query of queries) {
92
+ const items = query.data?.items;
93
+ if (!items) continue;
94
+ const match = items.find((mailbox) => mailbox.mailboxId === mailboxId);
95
+ if (match) return match.unseenCount;
96
+ }
97
+
98
+ return null;
99
+ };
@@ -0,0 +1,12 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ export const useDebouncedValue = <T>(value: T, delay: number): T => {
4
+ const [debouncedValue, setDebouncedValue] = useState(value);
5
+
6
+ useEffect(() => {
7
+ const timer = setTimeout(() => setDebouncedValue(value), delay);
8
+ return () => clearTimeout(timer);
9
+ }, [value, delay]);
10
+
11
+ return debouncedValue;
12
+ };
@@ -0,0 +1,103 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
4
+ import {
5
+ dropDeletedThreads,
6
+ removeMessagesFromItems,
7
+ } from "./useDeleteMessages.js";
8
+
9
+ const make = (
10
+ overrides: Partial<RemitImapThreadMessageResponse> & {
11
+ messageId: string;
12
+ threadMessageId: string;
13
+ },
14
+ ): RemitImapThreadMessageResponse =>
15
+ ({
16
+ threadId: "t1",
17
+ mailboxId: "mb1",
18
+ subject: "s",
19
+ fromName: "n",
20
+ fromEmail: "e",
21
+ sentDate: "2025-01-01T00:00:00Z",
22
+ snippet: "",
23
+ hasAttachment: false,
24
+ hasStars: false,
25
+ isRead: true,
26
+ ...overrides,
27
+ }) as RemitImapThreadMessageResponse;
28
+
29
+ describe("removeMessagesFromItems", () => {
30
+ test("drops items whose messageId is in the set", () => {
31
+ const items = [
32
+ make({ messageId: "m1", threadMessageId: "tm1" }),
33
+ make({ messageId: "m2", threadMessageId: "tm2" }),
34
+ make({ messageId: "m3", threadMessageId: "tm3" }),
35
+ ];
36
+ const got = removeMessagesFromItems(items, new Set(["m2"]));
37
+ assert.deepStrictEqual(
38
+ got.map((i) => i.messageId),
39
+ ["m1", "m3"],
40
+ );
41
+ });
42
+
43
+ test("returns the input unchanged when nothing matches", () => {
44
+ const items = [
45
+ make({ messageId: "m1", threadMessageId: "tm1" }),
46
+ make({ messageId: "m2", threadMessageId: "tm2" }),
47
+ ];
48
+ const got = removeMessagesFromItems(items, new Set(["does-not-exist"]));
49
+ assert.equal(got.length, 2);
50
+ });
51
+
52
+ test("returns an empty array when all items match", () => {
53
+ const items = [
54
+ make({ messageId: "m1", threadMessageId: "tm1" }),
55
+ make({ messageId: "m2", threadMessageId: "tm2" }),
56
+ ];
57
+ const got = removeMessagesFromItems(items, new Set(["m1", "m2"]));
58
+ assert.deepStrictEqual(got, []);
59
+ });
60
+
61
+ test("returns an empty array for empty input", () => {
62
+ const got = removeMessagesFromItems([], new Set(["m1"]));
63
+ assert.deepStrictEqual(got, []);
64
+ });
65
+ });
66
+
67
+ describe("dropDeletedThreads (#212)", () => {
68
+ // Belt-and-braces UI filter for issue #212. If the backend regresses and
69
+ // returns a soft-deleted row, the inbox list must still hide it. The
70
+ // dedicated helper makes the regression cheap to test without rendering
71
+ // React.
72
+
73
+ test("drops items with isDeleted=true and keeps the rest", () => {
74
+ const items = [
75
+ make({ messageId: "m1", threadMessageId: "tm1", isDeleted: false }),
76
+ make({ messageId: "m2", threadMessageId: "tm2", isDeleted: true }),
77
+ make({ messageId: "m3", threadMessageId: "tm3" }),
78
+ ];
79
+ const got = dropDeletedThreads(items);
80
+ assert.deepStrictEqual(
81
+ got.map((i) => i.messageId),
82
+ ["m1", "m3"],
83
+ );
84
+ });
85
+
86
+ test("returns the input unchanged when nothing is deleted", () => {
87
+ const items = [
88
+ make({ messageId: "m1", threadMessageId: "tm1" }),
89
+ make({ messageId: "m2", threadMessageId: "tm2", isDeleted: false }),
90
+ ];
91
+ const got = dropDeletedThreads(items);
92
+ assert.equal(got.length, 2);
93
+ });
94
+
95
+ test("returns an empty array when every row is deleted", () => {
96
+ const items = [
97
+ make({ messageId: "m1", threadMessageId: "tm1", isDeleted: true }),
98
+ make({ messageId: "m2", threadMessageId: "tm2", isDeleted: true }),
99
+ ];
100
+ const got = dropDeletedThreads(items);
101
+ assert.deepStrictEqual(got, []);
102
+ });
103
+ });