@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,246 @@
1
+ import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import { syncOperationsTriggerSync } from "@remit/api-http-client/sdk.gen.ts";
3
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
4
+ import { type QueryClient, useQueryClient } from "@tanstack/react-query";
5
+ import pMap from "p-map";
6
+ import { useEffect, useRef } from "react";
7
+ import { shouldEscalate } from "@/lib/error-classifier";
8
+ import { reportFatalError } from "@/lib/fatal-error";
9
+ import { getRuntimeConfig } from "@/runtime-config";
10
+
11
+ /**
12
+ * Mailboxes are considered stale 15 minutes after the last successful sync.
13
+ * Exposed for unit tests and possible future tuning.
14
+ */
15
+ export const STALENESS_THRESHOLD_MS = 15 * 60 * 1000;
16
+
17
+ const DEFAULT_POLL_INTERVAL_SECONDS = 5 * 60;
18
+
19
+ const parsePositiveIntSeconds = (
20
+ raw: string | undefined,
21
+ fallback: number,
22
+ ): number => {
23
+ if (!raw) return fallback;
24
+ const parsed = Number.parseInt(raw, 10);
25
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
26
+ };
27
+
28
+ /**
29
+ * Client-side online-poll interval (#1251): while an account's mail is open,
30
+ * the tab re-triggers the same sync the pull-to-refresh path uses on this
31
+ * cadence — the replacement for the server's removed "online tier".
32
+ */
33
+ export const POLL_INTERVAL_MS =
34
+ parsePositiveIntSeconds(
35
+ getRuntimeConfig().mailboxPollIntervalSeconds,
36
+ DEFAULT_POLL_INTERVAL_SECONDS,
37
+ ) * 1000;
38
+
39
+ /**
40
+ * Bounded concurrency for the per-poll fan-out across open accounts —
41
+ * mirrors `SCHEDULER_ENQUEUE_CONCURRENCY` in
42
+ * remit-imap-worker/src/scheduler/config.ts (never an unbounded
43
+ * `Promise.all`, see doc/rules/coding-standards.md). A browser tab realistically
44
+ * has a handful of accounts open at once, so this is a ceiling, not a tuned
45
+ * throughput target.
46
+ */
47
+ export const POLL_CONCURRENCY = 5;
48
+
49
+ /**
50
+ * Module-level guard: which accounts have already had a background sync
51
+ * fired in this browser tab's session. Survives layout re-mounts (route
52
+ * changes) so re-mounting MailLayout does not spam SQS.
53
+ */
54
+ const triggeredAccountIds = new Set<string>();
55
+
56
+ interface AccountLike {
57
+ accountId: string;
58
+ lastSyncAt?: number;
59
+ }
60
+
61
+ /**
62
+ * Pure selector: given a list of accounts and the current time, return
63
+ * the accountIds whose mailbox-list is stale and should be refreshed.
64
+ * `null`/`undefined` `lastSyncAt` is treated as stale unconditionally.
65
+ */
66
+ export const selectStaleAccountIds = (
67
+ accounts: AccountLike[],
68
+ now: number,
69
+ thresholdMs: number = STALENESS_THRESHOLD_MS,
70
+ ): string[] =>
71
+ accounts
72
+ .filter((account) => {
73
+ const last = account.lastSyncAt;
74
+ if (last === undefined || last === null) return true;
75
+ return now - last > thresholdMs;
76
+ })
77
+ .map((account) => account.accountId);
78
+
79
+ /**
80
+ * Pure predicate: has at least one full poll interval elapsed since the last
81
+ * poll? Shared by the recurring timer and the visibility-regain catch-up
82
+ * check so both use the exact same due-ness rule.
83
+ */
84
+ export const hasPollIntervalElapsed = (
85
+ now: number,
86
+ lastPollAt: number,
87
+ intervalMs: number = POLL_INTERVAL_MS,
88
+ ): boolean => now - lastPollAt >= intervalMs;
89
+
90
+ /**
91
+ * Pure selector: drop any accountId already mid-flight so a poll tick never
92
+ * stacks a second request onto one still in progress.
93
+ */
94
+ export const selectPollableAccountIds = (
95
+ accountIds: string[],
96
+ inFlight: ReadonlySet<string>,
97
+ ): string[] => accountIds.filter((id) => !inFlight.has(id));
98
+
99
+ /**
100
+ * Test-only helpers. Not exported from the public hook surface; consumers
101
+ * should never reach into the module-level Set directly.
102
+ */
103
+ export const __resetStaleAccountSyncGuard = (): void => {
104
+ triggeredAccountIds.clear();
105
+ };
106
+ export const __peekStaleAccountSyncGuard = (): ReadonlySet<string> =>
107
+ triggeredAccountIds;
108
+
109
+ /**
110
+ * Handle a failed background mailbox-sync trigger. This is a best-effort, direct
111
+ * SDK probe (not a React Query call), so we apply the same fail-fast decision by
112
+ * hand with the `softError` meta the call site owns: its non-5xx failures (the
113
+ * account's own 4xx, a statusless connectivity blip) stay soft — we drop the
114
+ * per-account guard so a later remount can retry, then log and move on. But a
115
+ * 5xx is OUR API broken, and per the contract (#1059) that always escalates to
116
+ * the full-screen overlay — even from a background trigger.
117
+ */
118
+ export const handleBackgroundSyncFailure = (
119
+ accountId: string,
120
+ error: unknown,
121
+ ): void => {
122
+ triggeredAccountIds.delete(accountId);
123
+ if (shouldEscalate(error, { softError: true })) {
124
+ reportFatalError(error);
125
+ return;
126
+ }
127
+ console.warn("[remit] background mailbox sync failed", {
128
+ accountId,
129
+ error,
130
+ });
131
+ };
132
+
133
+ /**
134
+ * Trigger the same sync the pull-to-refresh path uses and invalidate the
135
+ * mailbox-list query on success. `throwOnError: true` so a server failure
136
+ * rejects instead of silently resolving with `{ error }` (the default),
137
+ * which would let a 500 look like a successful sync and invalidate queries
138
+ * anyway.
139
+ */
140
+ const runBackgroundSync = (
141
+ accountId: string,
142
+ queryClient: QueryClient,
143
+ ): Promise<void> =>
144
+ syncOperationsTriggerSync({ path: { accountId }, throwOnError: true }).then(
145
+ () => {
146
+ queryClient.invalidateQueries({
147
+ queryKey: mailboxOperationsListMailboxesQueryKey({
148
+ path: { accountId },
149
+ }),
150
+ });
151
+ },
152
+ );
153
+
154
+ /**
155
+ * Auto-trigger a background mailbox-list sync for every stale account the
156
+ * first time MailLayout mounts in a session, then keep it fresh with a
157
+ * recurring online poll for as long as the mail app stays open (#1251) —
158
+ * the client-side replacement for the server's removed "online" sync tier.
159
+ * Fire-and-forget: does not block render, and silently logs failures (this
160
+ * is a best-effort background refresh — the user-visible UI for triggering
161
+ * is the "Refresh mailboxes" button in Settings).
162
+ */
163
+ export const useStaleAccountSync = (
164
+ accounts: RemitImapAccountResponse[],
165
+ ): void => {
166
+ const queryClient = useQueryClient();
167
+ // Hold the latest accounts in a ref so both effects can read them without
168
+ // re-running on every render; they re-run only when the joined accountId
169
+ // list actually changes (account added / removed).
170
+ const accountsRef = useRef(accounts);
171
+ accountsRef.current = accounts;
172
+ const stableKey = accounts.map((a) => a.accountId).join(",");
173
+
174
+ // biome-ignore lint/correctness/useExhaustiveDependencies: stableKey is a serialized account list used to trigger re-sync on account changes; adding it would cause issues without also referencing accountsRef
175
+ useEffect(() => {
176
+ const now = Date.now();
177
+ const stale = selectStaleAccountIds(accountsRef.current, now);
178
+ const fresh = stale.filter((id) => !triggeredAccountIds.has(id));
179
+ if (fresh.length === 0) return;
180
+
181
+ for (const accountId of fresh) {
182
+ triggeredAccountIds.add(accountId);
183
+ runBackgroundSync(accountId, queryClient).catch((err: unknown) => {
184
+ handleBackgroundSyncFailure(accountId, err);
185
+ });
186
+ }
187
+ }, [stableKey, queryClient]);
188
+
189
+ // biome-ignore lint/correctness/useExhaustiveDependencies: stableKey re-runs the poll loop when the account list changes; accountsRef supplies the live list each tick
190
+ useEffect(() => {
191
+ let lastPollAt = Date.now();
192
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
193
+ const inFlight = new Set<string>();
194
+
195
+ const poll = () => {
196
+ lastPollAt = Date.now();
197
+ const ids = selectPollableAccountIds(
198
+ accountsRef.current.map((a) => a.accountId),
199
+ inFlight,
200
+ );
201
+ // Mark every selected id in flight before dispatch (not as pMap
202
+ // starts each one) so a concurrency-queued id still counts as
203
+ // "already polling" for the next tick's `selectPollableAccountIds`
204
+ // check — no stacked requests even while waiting for a free slot.
205
+ for (const accountId of ids) inFlight.add(accountId);
206
+ void pMap(
207
+ ids,
208
+ (accountId) =>
209
+ runBackgroundSync(accountId, queryClient)
210
+ .catch((err: unknown) => {
211
+ handleBackgroundSyncFailure(accountId, err);
212
+ })
213
+ .finally(() => {
214
+ inFlight.delete(accountId);
215
+ }),
216
+ { concurrency: POLL_CONCURRENCY },
217
+ );
218
+ };
219
+
220
+ const tick = () => {
221
+ if (document.visibilityState === "visible") {
222
+ poll();
223
+ timeoutId = setTimeout(tick, POLL_INTERVAL_MS);
224
+ return;
225
+ }
226
+ // Hidden: don't reschedule. `handleVisibilityChange` resumes the loop
227
+ // (with an immediate catch-up poll) once the tab is visible again.
228
+ };
229
+
230
+ const handleVisibilityChange = () => {
231
+ if (document.visibilityState !== "visible") return;
232
+ if (!hasPollIntervalElapsed(Date.now(), lastPollAt)) return;
233
+ if (timeoutId !== undefined) clearTimeout(timeoutId);
234
+ poll();
235
+ timeoutId = setTimeout(tick, POLL_INTERVAL_MS);
236
+ };
237
+
238
+ timeoutId = setTimeout(tick, POLL_INTERVAL_MS);
239
+ document.addEventListener("visibilitychange", handleVisibilityChange);
240
+
241
+ return () => {
242
+ if (timeoutId !== undefined) clearTimeout(timeoutId);
243
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
244
+ };
245
+ }, [stableKey, queryClient]);
246
+ };
@@ -0,0 +1,35 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import { resolveSwipe } from "./useSwipeNavigation.js";
4
+
5
+ const THRESHOLD = 60;
6
+
7
+ describe("resolveSwipe", () => {
8
+ test("classifies a left swipe (next) when dragging right→left", () => {
9
+ assert.strictEqual(resolveSwipe(-80, 0, THRESHOLD).direction, "left");
10
+ });
11
+
12
+ test("classifies a right swipe (previous) when dragging left→right", () => {
13
+ assert.strictEqual(resolveSwipe(80, 0, THRESHOLD).direction, "right");
14
+ });
15
+
16
+ test("ignores a gesture shorter than the threshold", () => {
17
+ assert.strictEqual(resolveSwipe(-40, 0, THRESHOLD).direction, null);
18
+ assert.strictEqual(resolveSwipe(59, 0, THRESHOLD).direction, null);
19
+ });
20
+
21
+ test("ignores a mostly-vertical gesture so it never hijacks scroll", () => {
22
+ // Long horizontal travel but even longer vertical travel: this is a scroll.
23
+ assert.strictEqual(resolveSwipe(70, 200, THRESHOLD).direction, null);
24
+ assert.strictEqual(resolveSwipe(-70, -200, THRESHOLD).direction, null);
25
+ });
26
+
27
+ test("accepts a horizontal swipe with mild vertical drift", () => {
28
+ assert.strictEqual(resolveSwipe(-100, 20, THRESHOLD).direction, "left");
29
+ });
30
+
31
+ test("rejects a diagonal gesture that is not horizontally dominant", () => {
32
+ // dx 70, dy 60: not 1.5x dominant, so treated as ambiguous → no swipe.
33
+ assert.strictEqual(resolveSwipe(70, 60, THRESHOLD).direction, null);
34
+ });
35
+ });
@@ -0,0 +1,99 @@
1
+ import { useCallback, useRef } from "react";
2
+
3
+ export type SwipeDirection = "left" | "right";
4
+
5
+ interface UseSwipeNavigationOptions {
6
+ /**
7
+ * Swipe left (content advances, finger moves right→left): go to the NEXT
8
+ * item — the platform convention for paging through content (photos, mail).
9
+ */
10
+ onSwipeLeft?: () => void;
11
+ /** Swipe right: go to the PREVIOUS item. */
12
+ onSwipeRight?: () => void;
13
+ /** Minimum horizontal travel (px) before a swipe registers. */
14
+ thresholdPx?: number;
15
+ }
16
+
17
+ interface SwipeHandlers {
18
+ onPointerDown: (e: React.PointerEvent) => void;
19
+ onPointerMove: (e: React.PointerEvent) => void;
20
+ onPointerUp: (e: React.PointerEvent) => void;
21
+ onPointerCancel: () => void;
22
+ }
23
+
24
+ const DEFAULT_THRESHOLD = 60;
25
+ /**
26
+ * A gesture only counts as a horizontal swipe when its horizontal travel
27
+ * dominates the vertical. Anything more vertical than this is the user
28
+ * scrolling the message body, and we leave it alone.
29
+ */
30
+ const HORIZONTAL_DOMINANCE = 1.5;
31
+
32
+ export interface SwipeResolution {
33
+ direction: SwipeDirection | null;
34
+ }
35
+
36
+ /**
37
+ * Pure swipe classifier. Returns the swipe direction, or `null` when the
38
+ * gesture is too short or too vertical to count as a horizontal swipe (so it
39
+ * never hijacks vertical scrolling).
40
+ */
41
+ export const resolveSwipe = (
42
+ dx: number,
43
+ dy: number,
44
+ thresholdPx: number,
45
+ ): SwipeResolution => {
46
+ const absX = Math.abs(dx);
47
+ const absY = Math.abs(dy);
48
+ if (absX < thresholdPx) return { direction: null };
49
+ if (absX < absY * HORIZONTAL_DOMINANCE) return { direction: null };
50
+ return { direction: dx < 0 ? "left" : "right" };
51
+ };
52
+
53
+ /**
54
+ * Detects horizontal swipe gestures from pointer events and routes them to
55
+ * next/previous callbacks. Only reacts to touch/pen pointers so mouse drags on
56
+ * desktop never trigger navigation. Vertical scroll is left untouched: a
57
+ * gesture must travel mostly sideways to register.
58
+ */
59
+ export const useSwipeNavigation = ({
60
+ onSwipeLeft,
61
+ onSwipeRight,
62
+ thresholdPx = DEFAULT_THRESHOLD,
63
+ }: UseSwipeNavigationOptions): { handlers: SwipeHandlers } => {
64
+ const startRef = useRef<{ x: number; y: number } | null>(null);
65
+
66
+ const onPointerDown = useCallback((e: React.PointerEvent) => {
67
+ if (e.pointerType === "mouse") {
68
+ startRef.current = null;
69
+ return;
70
+ }
71
+ startRef.current = { x: e.clientX, y: e.clientY };
72
+ }, []);
73
+
74
+ const onPointerMove = useCallback(() => {}, []);
75
+
76
+ const onPointerUp = useCallback(
77
+ (e: React.PointerEvent) => {
78
+ const start = startRef.current;
79
+ startRef.current = null;
80
+ if (!start) return;
81
+ const { direction } = resolveSwipe(
82
+ e.clientX - start.x,
83
+ e.clientY - start.y,
84
+ thresholdPx,
85
+ );
86
+ if (direction === "left") onSwipeLeft?.();
87
+ if (direction === "right") onSwipeRight?.();
88
+ },
89
+ [onSwipeLeft, onSwipeRight, thresholdPx],
90
+ );
91
+
92
+ const onPointerCancel = useCallback(() => {
93
+ startRef.current = null;
94
+ }, []);
95
+
96
+ return {
97
+ handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel },
98
+ };
99
+ };
@@ -0,0 +1,187 @@
1
+ import {
2
+ messageOperationsUpdateMessageFlagsMutation,
3
+ threadDetailOperationsListThreadMessagesQueryKey,
4
+ threadOperationsListThreadsQueryKey,
5
+ threadOperationsSearchThreadsQueryKey,
6
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
7
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
8
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
9
+ import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
10
+ import { formatErrorDetail } from "@/components/ui/error-banners";
11
+
12
+ interface UseToggleStarOptions {
13
+ threadId: string;
14
+ mailboxId: string;
15
+ }
16
+
17
+ interface ThreadMessagesData {
18
+ items: RemitImapThreadMessageResponse[];
19
+ [key: string]: unknown;
20
+ }
21
+
22
+ interface ThreadsListPage {
23
+ items: RemitImapThreadMessageResponse[];
24
+ [key: string]: unknown;
25
+ }
26
+
27
+ interface ThreadsListData {
28
+ pages: ThreadsListPage[];
29
+ pageParams: Array<string | undefined>;
30
+ }
31
+
32
+ interface SnapshotEntry<T> {
33
+ queryKey: readonly unknown[];
34
+ data: T;
35
+ }
36
+
37
+ interface ToggleStarContext {
38
+ threadMessagesPrefix: readonly unknown[];
39
+ threadsListPrefix: readonly unknown[];
40
+ threadsSearchPrefix: readonly unknown[];
41
+ previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
42
+ previousThreadsList: SnapshotEntry<ThreadsListData>[];
43
+ }
44
+
45
+ const toggleStarsInItems = (
46
+ items: RemitImapThreadMessageResponse[],
47
+ messageId: string,
48
+ nextStarred: boolean,
49
+ ): RemitImapThreadMessageResponse[] =>
50
+ items.map((item) =>
51
+ item.messageId === messageId ? { ...item, hasStars: nextStarred } : item,
52
+ );
53
+
54
+ export const useToggleStar = ({
55
+ threadId,
56
+ mailboxId,
57
+ }: UseToggleStarOptions) => {
58
+ const queryClient = useQueryClient();
59
+ const { pushError } = useErrorBanners();
60
+
61
+ const { mutate, isPending, variables } = useMutation({
62
+ ...messageOperationsUpdateMessageFlagsMutation(),
63
+ onMutate: async (vars): Promise<ToggleStarContext> => {
64
+ const messageId = vars.path.messageId;
65
+ const nextStarred = vars.body.isStarred ?? false;
66
+
67
+ // Use partial-key prefixes so the cancel/snapshot/patch covers the
68
+ // full key (which includes query options like { order: "desc",
69
+ // mailboxId }) without us having to enumerate every variant. React
70
+ // Query matches by prefix on the first key element.
71
+ const threadMessagesPrefix =
72
+ threadDetailOperationsListThreadMessagesQueryKey({
73
+ path: { threadId },
74
+ });
75
+ const threadsListPrefix = threadOperationsListThreadsQueryKey({
76
+ path: { mailboxId },
77
+ });
78
+ const threadsSearchPrefix = threadOperationsSearchThreadsQueryKey({
79
+ path: { mailboxId },
80
+ });
81
+
82
+ await Promise.all([
83
+ queryClient.cancelQueries({ queryKey: threadMessagesPrefix }),
84
+ queryClient.cancelQueries({ queryKey: threadsListPrefix }),
85
+ queryClient.cancelQueries({ queryKey: threadsSearchPrefix }),
86
+ ]);
87
+
88
+ const previousThreadMessages = queryClient
89
+ .getQueriesData<ThreadMessagesData>({ queryKey: threadMessagesPrefix })
90
+ .filter(
91
+ (entry): entry is [readonly unknown[], ThreadMessagesData] =>
92
+ entry[1] !== undefined,
93
+ )
94
+ .map(([queryKey, data]) => ({ queryKey, data }));
95
+
96
+ const previousThreadsList = queryClient
97
+ .getQueriesData<ThreadsListData>({ queryKey: threadsListPrefix })
98
+ .concat(
99
+ queryClient.getQueriesData<ThreadsListData>({
100
+ queryKey: threadsSearchPrefix,
101
+ }),
102
+ )
103
+ .filter(
104
+ (entry): entry is [readonly unknown[], ThreadsListData] =>
105
+ entry[1] !== undefined,
106
+ )
107
+ .map(([queryKey, data]) => ({ queryKey, data }));
108
+
109
+ queryClient.setQueriesData<ThreadMessagesData>(
110
+ { queryKey: threadMessagesPrefix },
111
+ (old) => {
112
+ if (!old) return old;
113
+ return {
114
+ ...old,
115
+ items: toggleStarsInItems(old.items, messageId, nextStarred),
116
+ };
117
+ },
118
+ );
119
+
120
+ const patchListData = (old: ThreadsListData | undefined) => {
121
+ if (!old) return old;
122
+ return {
123
+ ...old,
124
+ pages: old.pages.map((page) => ({
125
+ ...page,
126
+ items: toggleStarsInItems(page.items, messageId, nextStarred),
127
+ })),
128
+ };
129
+ };
130
+
131
+ queryClient.setQueriesData<ThreadsListData>(
132
+ { queryKey: threadsListPrefix },
133
+ patchListData,
134
+ );
135
+ queryClient.setQueriesData<ThreadsListData>(
136
+ { queryKey: threadsSearchPrefix },
137
+ patchListData,
138
+ );
139
+
140
+ return {
141
+ threadMessagesPrefix,
142
+ threadsListPrefix,
143
+ threadsSearchPrefix,
144
+ previousThreadMessages,
145
+ previousThreadsList,
146
+ };
147
+ },
148
+ onError: (err, vars, context) => {
149
+ if (context) {
150
+ for (const entry of context.previousThreadMessages) {
151
+ queryClient.setQueryData(entry.queryKey, entry.data);
152
+ }
153
+ for (const entry of context.previousThreadsList) {
154
+ queryClient.setQueryData(entry.queryKey, entry.data);
155
+ }
156
+ }
157
+ const nextStarred = vars.body.isStarred ?? false;
158
+ pushError({
159
+ title: nextStarred
160
+ ? "Couldn't star message"
161
+ : "Couldn't unstar message",
162
+ detail: formatErrorDetail(err),
163
+ });
164
+ },
165
+ onSettled: (_data, _err, _vars, context) => {
166
+ if (!context) return;
167
+ queryClient.invalidateQueries({ queryKey: context.threadMessagesPrefix });
168
+ queryClient.invalidateQueries({ queryKey: context.threadsListPrefix });
169
+ queryClient.invalidateQueries({ queryKey: context.threadsSearchPrefix });
170
+ },
171
+ });
172
+
173
+ const toggleStar = (messageId: string, currentlyStarred: boolean) => {
174
+ mutate({
175
+ path: { messageId },
176
+ body: {
177
+ isStarred: !currentlyStarred,
178
+ },
179
+ });
180
+ };
181
+
182
+ return {
183
+ toggleStar,
184
+ isPending,
185
+ pendingMessageId: isPending ? variables?.path.messageId : undefined,
186
+ };
187
+ };
@@ -0,0 +1,102 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import type {
4
+ RemitImapDescribeMessageResponse,
5
+ RemitImapEnvelopeAddressResponse,
6
+ } from "@remit/api-http-client/types.gen.ts";
7
+ import { patchDescribeMessage, patchEnvelopeAddress } from "./useToggleTrusted";
8
+
9
+ const NOW = 1_700_000_000_000;
10
+
11
+ const makeAddr = (
12
+ overrides: Partial<RemitImapEnvelopeAddressResponse> & {
13
+ addressId: string;
14
+ },
15
+ ): RemitImapEnvelopeAddressResponse =>
16
+ ({
17
+ normalizedEmail: "x@example.com",
18
+ addressRole: "from",
19
+ addressOrder: 0,
20
+ ...overrides,
21
+ }) as RemitImapEnvelopeAddressResponse;
22
+
23
+ describe("patchEnvelopeAddress", () => {
24
+ test("ignores addresses with non-matching addressId", () => {
25
+ const addr = makeAddr({ addressId: "addr-other" });
26
+ const result = patchEnvelopeAddress(addr, "addr-target", true, NOW);
27
+ assert.equal(result, addr);
28
+ });
29
+
30
+ test("sets trusted flag when toggled on", () => {
31
+ const addr = makeAddr({ addressId: "addr-1" });
32
+ const result = patchEnvelopeAddress(addr, "addr-1", true, NOW);
33
+ assert.equal(result.flags?.trusted?.value, true);
34
+ assert.equal(result.flags?.trusted?.setAt, NOW);
35
+ });
36
+
37
+ test("removes trusted flag when toggled off", () => {
38
+ const addr = makeAddr({
39
+ addressId: "addr-1",
40
+ flags: { trusted: { value: true, setAt: 1 } },
41
+ });
42
+ const result = patchEnvelopeAddress(addr, "addr-1", false, NOW);
43
+ assert.equal(result.flags?.trusted, undefined);
44
+ });
45
+
46
+ test("preserves other flags when toggling trust off", () => {
47
+ const addr = makeAddr({
48
+ addressId: "addr-1",
49
+ flags: {
50
+ trusted: { value: true, setAt: 1 },
51
+ vip: { value: true, setAt: 2 },
52
+ },
53
+ });
54
+ const result = patchEnvelopeAddress(addr, "addr-1", false, NOW);
55
+ assert.equal(result.flags?.vip?.value, true);
56
+ assert.equal(result.flags?.trusted, undefined);
57
+ });
58
+ });
59
+
60
+ describe("patchDescribeMessage", () => {
61
+ const baseDescribe = (
62
+ from: RemitImapEnvelopeAddressResponse[],
63
+ ): RemitImapDescribeMessageResponse =>
64
+ ({
65
+ message: {
66
+ messageId: "m",
67
+ mailboxId: "mb",
68
+ uid: 1,
69
+ rfc822Size: 1,
70
+ internalDate: 1,
71
+ },
72
+ envelope: {
73
+ messageId: "m",
74
+ date: 1,
75
+ from,
76
+ to: [],
77
+ cc: [],
78
+ bcc: [],
79
+ replyTo: [],
80
+ senderTrust: "unknown",
81
+ },
82
+ flags: [],
83
+ bodyParts: [],
84
+ references: [],
85
+ }) as RemitImapDescribeMessageResponse;
86
+
87
+ test("flips only the matching address across all roles", () => {
88
+ const target = makeAddr({ addressId: "a-1" });
89
+ const other = makeAddr({ addressId: "a-2" });
90
+ const data = baseDescribe([target, other]);
91
+ const result = patchDescribeMessage(data, "a-1", true, NOW);
92
+ assert.equal(result.envelope.from[0].flags?.trusted?.value, true);
93
+ assert.equal(result.envelope.from[1].flags?.trusted, undefined);
94
+ });
95
+
96
+ test("rollback semantics: original data is unchanged (immutable patch)", () => {
97
+ const target = makeAddr({ addressId: "a-1" });
98
+ const data = baseDescribe([target]);
99
+ patchDescribeMessage(data, "a-1", true, NOW);
100
+ assert.equal(data.envelope.from[0].flags, undefined);
101
+ });
102
+ });