@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,211 @@
1
+ import { useCallback, useState } from "react";
2
+
3
+ /**
4
+ * Mouse/keyboard modifier flags read off a row click, used to drive desktop
5
+ * multi-select semantics (shift = range, cmd/ctrl = toggle, plain = open).
6
+ */
7
+ export interface SelectionModifiers {
8
+ shiftKey: boolean;
9
+ metaKey: boolean;
10
+ ctrlKey: boolean;
11
+ }
12
+
13
+ interface UseSelectionOptions<T> {
14
+ /** Function to extract ID from an item */
15
+ getId: (item: T) => string;
16
+ }
17
+
18
+ interface UseSelectionReturn {
19
+ /** Set of currently selected item IDs */
20
+ selectedIds: Set<string>;
21
+ /** Number of selected items */
22
+ selectedCount: number;
23
+ /** Whether any items are selected */
24
+ hasSelection: boolean;
25
+ /** Check if a specific item is selected */
26
+ isSelected: (id: string) => boolean;
27
+ /** Toggle selection for a single item (updates the range anchor) */
28
+ toggle: (id: string) => void;
29
+ /** Select a single item (adds to selection, updates the range anchor) */
30
+ select: (id: string) => void;
31
+ /** Deselect a single item */
32
+ deselect: (id: string) => void;
33
+ /** Select all items */
34
+ selectAll: (ids: string[]) => void;
35
+ /** Clear all selections (also clears the range anchor) */
36
+ clearSelection: () => void;
37
+ /** Toggle selection for all items */
38
+ toggleAll: (ids: string[]) => void;
39
+ /**
40
+ * Add the contiguous range of ids from the current anchor to `targetId`
41
+ * (inclusive) to the selection, using `orderedIds` for display order. If
42
+ * no anchor exists, selects only `targetId` and makes it the anchor.
43
+ */
44
+ selectRange: (orderedIds: string[], targetId: string) => void;
45
+ /**
46
+ * Set the range anchor without changing the selection set. Used by a plain
47
+ * click that navigates but should seed the anchor for a later shift-click.
48
+ */
49
+ setAnchor: (id: string) => void;
50
+ /**
51
+ * The id of the row that anchors shift-range selection. `undefined` when
52
+ * nothing has been selected yet.
53
+ */
54
+ anchorId: string | undefined;
55
+ }
56
+
57
+ /**
58
+ * Compute the inclusive slice of ids spanning from `anchorId` to `targetId`
59
+ * in `orderedIds`. Pure so it can be unit-tested without a DOM.
60
+ *
61
+ * - Direction-agnostic: works whether the target sits above or below the anchor.
62
+ * - Missing anchor (or anchor not in the list): returns just `[targetId]`.
63
+ * - Target not in the list: returns `[]` (nothing to select).
64
+ */
65
+ export const computeRange = (
66
+ orderedIds: string[],
67
+ anchorId: string | undefined,
68
+ targetId: string,
69
+ ): string[] => {
70
+ const targetIndex = orderedIds.indexOf(targetId);
71
+ if (targetIndex === -1) return [];
72
+
73
+ const anchorIndex =
74
+ anchorId === undefined ? -1 : orderedIds.indexOf(anchorId);
75
+ if (anchorIndex === -1) return [targetId];
76
+
77
+ const start = Math.min(anchorIndex, targetIndex);
78
+ const end = Math.max(anchorIndex, targetIndex);
79
+ return orderedIds.slice(start, end + 1);
80
+ };
81
+
82
+ /**
83
+ * Compute the id one step from `focusId` in `orderedIds`, clamped at the ends.
84
+ * Pure so the shift-arrow range-extend math can be unit-tested without a DOM.
85
+ *
86
+ * - `direction` is -1 for up (previous) or +1 for down (next).
87
+ * - Missing focus (or focus not in the list): returns the first id for down,
88
+ * the last id for up, or `undefined` when the list is empty.
89
+ * - At a boundary: returns the same `focusId` (no wrap).
90
+ */
91
+ export const nextFocusId = (
92
+ orderedIds: string[],
93
+ focusId: string | undefined,
94
+ direction: -1 | 1,
95
+ ): string | undefined => {
96
+ if (orderedIds.length === 0) return undefined;
97
+
98
+ const currentIndex = focusId === undefined ? -1 : orderedIds.indexOf(focusId);
99
+ if (currentIndex === -1) {
100
+ return direction > 0 ? orderedIds[0] : orderedIds[orderedIds.length - 1];
101
+ }
102
+
103
+ const nextIndex = Math.min(
104
+ Math.max(currentIndex + direction, 0),
105
+ orderedIds.length - 1,
106
+ );
107
+ return orderedIds[nextIndex];
108
+ };
109
+
110
+ /**
111
+ * Hook for managing selection state in lists.
112
+ * Supports single and multi-select operations.
113
+ */
114
+ export const useSelection = <T>(
115
+ _options?: UseSelectionOptions<T>,
116
+ ): UseSelectionReturn => {
117
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
118
+ const [anchorId, setAnchorId] = useState<string | undefined>(undefined);
119
+
120
+ const isSelected = useCallback(
121
+ (id: string) => selectedIds.has(id),
122
+ [selectedIds],
123
+ );
124
+
125
+ const toggle = useCallback((id: string) => {
126
+ setAnchorId(id);
127
+ setSelectedIds((prev) => {
128
+ const next = new Set(prev);
129
+ if (next.has(id)) {
130
+ next.delete(id);
131
+ } else {
132
+ next.add(id);
133
+ }
134
+ return next;
135
+ });
136
+ }, []);
137
+
138
+ const select = useCallback((id: string) => {
139
+ setAnchorId(id);
140
+ setSelectedIds((prev) => {
141
+ if (prev.has(id)) return prev;
142
+ const next = new Set(prev);
143
+ next.add(id);
144
+ return next;
145
+ });
146
+ }, []);
147
+
148
+ const deselect = useCallback((id: string) => {
149
+ setSelectedIds((prev) => {
150
+ if (!prev.has(id)) return prev;
151
+ const next = new Set(prev);
152
+ next.delete(id);
153
+ return next;
154
+ });
155
+ }, []);
156
+
157
+ const selectAll = useCallback((ids: string[]) => {
158
+ setSelectedIds(new Set(ids));
159
+ }, []);
160
+
161
+ const clearSelection = useCallback(() => {
162
+ setAnchorId(undefined);
163
+ setSelectedIds(new Set());
164
+ }, []);
165
+
166
+ const toggleAll = useCallback((ids: string[]) => {
167
+ setSelectedIds((prev) => {
168
+ const allSelected = ids.every((id) => prev.has(id));
169
+ return allSelected ? new Set() : new Set(ids);
170
+ });
171
+ }, []);
172
+
173
+ const setAnchor = useCallback((id: string) => {
174
+ setAnchorId(id);
175
+ }, []);
176
+
177
+ const selectRange = useCallback(
178
+ (orderedIds: string[], targetId: string) => {
179
+ setSelectedIds((prev) => {
180
+ const range = computeRange(orderedIds, anchorId, targetId);
181
+ if (range.length === 0) return prev;
182
+ const next = new Set(prev);
183
+ for (const id of range) {
184
+ next.add(id);
185
+ }
186
+ return next;
187
+ });
188
+ // Keep the original anchor so subsequent shift-clicks extend from the
189
+ // same origin (Apple Mail / Gmail behavior). Only seed the anchor when
190
+ // none exists yet.
191
+ setAnchorId((prev) => prev ?? targetId);
192
+ },
193
+ [anchorId],
194
+ );
195
+
196
+ return {
197
+ selectedIds,
198
+ selectedCount: selectedIds.size,
199
+ hasSelection: selectedIds.size > 0,
200
+ isSelected,
201
+ toggle,
202
+ select,
203
+ deselect,
204
+ selectAll,
205
+ clearSelection,
206
+ toggleAll,
207
+ selectRange,
208
+ setAnchor,
209
+ anchorId,
210
+ };
211
+ };
@@ -0,0 +1,94 @@
1
+ import { semanticSearchOperationsSemanticSearchOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type { RemitImapSemanticSearchResult } from "@remit/api-http-client/types.gen.ts";
3
+ import { MessageCategory } from "@remit/domain-enums";
4
+ import { useQuery } from "@tanstack/react-query";
5
+ import { useMailContext } from "@/lib/mail-context";
6
+ import { normalizeSearchQuery } from "@/lib/search-query";
7
+ import { parseSearchTokens } from "@/lib/search-tokens";
8
+
9
+ /** Cap the "Related" section; the literal "Top matches" is the primary surface. */
10
+ const SEMANTIC_RESULT_LIMIT = 20;
11
+
12
+ const MESSAGE_CATEGORIES = new Set<string>(Object.values(MessageCategory));
13
+
14
+ /** Narrow a filter-chip id to a real category, or `undefined` for `"all"` / any unknown value. */
15
+ const toCategoryParam = (
16
+ filterCategory: string | undefined,
17
+ ): (typeof MessageCategory)[keyof typeof MessageCategory] | undefined =>
18
+ filterCategory !== undefined && MESSAGE_CATEGORIES.has(filterCategory)
19
+ ? (filterCategory as (typeof MessageCategory)[keyof typeof MessageCategory])
20
+ : undefined;
21
+
22
+ interface UseSemanticSearchParams {
23
+ /**
24
+ * Restrict results to a single mailbox (the scoped inbox). Omit for the
25
+ * cross-account daily brief, where semantic search spans every account.
26
+ */
27
+ mailboxId?: string;
28
+ /**
29
+ * Restrict results to a single header-derived category. The sentinel `"all"`
30
+ * means no category scope — the param is omitted from the request entirely.
31
+ */
32
+ filterCategory?: string;
33
+ }
34
+
35
+ /**
36
+ * Fetch semantic-search hits for the active query (one source of truth: the
37
+ * debounced `searchQuery` in `MailContext`). Scoping mirrors the literal search:
38
+ * pass `mailboxId` for a scoped inbox, omit it for the global brief. Disabled
39
+ * until the query is non-empty so an empty field issues no request.
40
+ *
41
+ * Filter tokens (`has:attachment`, `is:unread`, `before:`/`after:`) parsed from
42
+ * the query map onto the search API's own filter params. `from:` and
43
+ * `account:` have no equivalent on `GET /search/semantic` (no sender or
44
+ * account filter) — both still render as chips and narrow the literal engine,
45
+ * but never reach the semantic request (`account:` is a documented gap, see
46
+ * doc/design/flows/06-search.md — the semantic index is per account config).
47
+ * `in:` resolves to a mailboxId and, when present, overrides the caller's
48
+ * `mailboxId` — so typing `in:archive` re-scopes the semantic search to that
49
+ * mailbox even from the cross-account daily brief.
50
+ */
51
+ export function useSemanticSearch({
52
+ mailboxId,
53
+ filterCategory,
54
+ }: UseSemanticSearchParams = {}): {
55
+ hits: RemitImapSemanticSearchResult[];
56
+ isLoading: boolean;
57
+ } {
58
+ const { searchQuery, mailboxNameIndex, accountNameIndex } = useMailContext();
59
+ const normalizedQuery = normalizeSearchQuery(searchQuery);
60
+ const { freeText, tokens } = parseSearchTokens(normalizedQuery, {
61
+ mailboxesByName: mailboxNameIndex,
62
+ accountsByName: accountNameIndex,
63
+ });
64
+ const enabled = normalizedQuery.length > 0;
65
+
66
+ const category = toCategoryParam(filterCategory);
67
+ const hasAttachment = tokens.some((t) => t.type === "hasAttachment")
68
+ ? true
69
+ : undefined;
70
+ const isRead = tokens.some((t) => t.type === "isUnread") ? false : undefined;
71
+ const afterToken = tokens.find((t) => t.type === "after");
72
+ const beforeToken = tokens.find((t) => t.type === "before");
73
+ const inToken = tokens.find((t) => t.type === "in");
74
+ const effectiveMailboxId = inToken?.mailboxId ?? mailboxId;
75
+
76
+ const { data, isLoading } = useQuery({
77
+ ...semanticSearchOperationsSemanticSearchOptions({
78
+ query: {
79
+ query: freeText,
80
+ mailboxId: effectiveMailboxId,
81
+ limit: SEMANTIC_RESULT_LIMIT,
82
+ ...(category !== undefined ? { category } : {}),
83
+ ...(hasAttachment !== undefined ? { hasAttachment } : {}),
84
+ ...(isRead !== undefined ? { isRead } : {}),
85
+ ...(afterToken ? { sentDateFrom: afterToken.epochSeconds } : {}),
86
+ ...(beforeToken ? { sentDateTo: beforeToken.epochSeconds } : {}),
87
+ },
88
+ }),
89
+ enabled,
90
+ staleTime: 60_000,
91
+ });
92
+
93
+ return { hits: data?.items ?? [], isLoading: enabled && isLoading };
94
+ }
@@ -0,0 +1,69 @@
1
+ import {
2
+ accountDetailOperationsUpdateAccountMutation,
3
+ configOperationsGetConfigOptions,
4
+ configOperationsGetConfigQueryKey,
5
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
6
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
7
+ import { useCallback, useMemo } from "react";
8
+ import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
9
+ import { buildMutationErrorBanner } from "@/components/ui/error-banners";
10
+
11
+ export interface SignatureData {
12
+ html: string;
13
+ plainText: string;
14
+ }
15
+
16
+ const EMPTY_SIGNATURE: SignatureData = { html: "", plainText: "" };
17
+
18
+ export const useSignature = (accountId?: string) => {
19
+ const queryClient = useQueryClient();
20
+ const { pushError } = useErrorBanners();
21
+
22
+ const { data: config } = useQuery({
23
+ ...configOperationsGetConfigOptions(),
24
+ staleTime: Infinity,
25
+ });
26
+
27
+ const signature = useMemo<SignatureData>(() => {
28
+ if (!accountId || !config) return EMPTY_SIGNATURE;
29
+ const account = config.accounts.find((a) => a.accountId === accountId);
30
+ if (!account) return EMPTY_SIGNATURE;
31
+ return {
32
+ html: account.signatureHtml ?? "",
33
+ plainText: account.signaturePlainText ?? "",
34
+ };
35
+ }, [accountId, config]);
36
+
37
+ const mutation = useMutation({
38
+ ...accountDetailOperationsUpdateAccountMutation(),
39
+ onSuccess: () => {
40
+ queryClient.invalidateQueries({
41
+ queryKey: configOperationsGetConfigQueryKey(),
42
+ });
43
+ },
44
+ onError: (error) => {
45
+ // Saving the signature failed silently before — surface it. A fatal
46
+ // 5xx also escalates through the global MutationCache.onError sink.
47
+ pushError(
48
+ buildMutationErrorBanner(
49
+ "Couldn't save signature",
50
+ "The signature wasn't saved.",
51
+ error,
52
+ ),
53
+ );
54
+ },
55
+ });
56
+
57
+ const setSignature = useCallback(
58
+ (html: string, plainText: string) => {
59
+ if (!accountId) return;
60
+ mutation.mutate({
61
+ path: { accountId },
62
+ body: { signatureHtml: html, signaturePlainText: plainText },
63
+ });
64
+ },
65
+ [accountId, mutation],
66
+ );
67
+
68
+ return { signature, setSignature, isSaving: mutation.isPending };
69
+ };
@@ -0,0 +1,211 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, describe, test } from "node:test";
3
+ import { ApiError } from "../lib/api.js";
4
+ import { __resetFatalError, subscribeFatalError } from "../lib/fatal-error.js";
5
+ import {
6
+ __peekStaleAccountSyncGuard,
7
+ __resetStaleAccountSyncGuard,
8
+ handleBackgroundSyncFailure,
9
+ hasPollIntervalElapsed,
10
+ POLL_INTERVAL_MS,
11
+ STALENESS_THRESHOLD_MS,
12
+ selectPollableAccountIds,
13
+ selectStaleAccountIds,
14
+ } from "./useStaleAccountSync.js";
15
+
16
+ const NOW = 1_700_000_000_000;
17
+
18
+ describe("STALENESS_THRESHOLD_MS", () => {
19
+ test("equals 15 minutes in milliseconds", () => {
20
+ assert.equal(STALENESS_THRESHOLD_MS, 15 * 60 * 1000);
21
+ });
22
+ });
23
+
24
+ describe("selectStaleAccountIds", () => {
25
+ test("treats accounts with undefined lastSyncAt as stale", () => {
26
+ const ids = selectStaleAccountIds(
27
+ [{ accountId: "a-1", lastSyncAt: undefined }],
28
+ NOW,
29
+ );
30
+ assert.deepEqual(ids, ["a-1"]);
31
+ });
32
+
33
+ test("treats accounts with null lastSyncAt as stale", () => {
34
+ const ids = selectStaleAccountIds(
35
+ // Real network responses can deserialize undefined as null in some
36
+ // transports; cover both shapes.
37
+ [{ accountId: "a-1", lastSyncAt: null as unknown as undefined }],
38
+ NOW,
39
+ );
40
+ assert.deepEqual(ids, ["a-1"]);
41
+ });
42
+
43
+ test("returns the account when lastSyncAt is older than the threshold", () => {
44
+ const ids = selectStaleAccountIds(
45
+ [
46
+ {
47
+ accountId: "a-1",
48
+ lastSyncAt: NOW - STALENESS_THRESHOLD_MS - 1000,
49
+ },
50
+ ],
51
+ NOW,
52
+ );
53
+ assert.deepEqual(ids, ["a-1"]);
54
+ });
55
+
56
+ test("does not return an account that synced inside the threshold", () => {
57
+ const ids = selectStaleAccountIds(
58
+ [{ accountId: "a-1", lastSyncAt: NOW - 60_000 }],
59
+ NOW,
60
+ );
61
+ assert.deepEqual(ids, []);
62
+ });
63
+
64
+ test("returns only the stale accounts when mixed", () => {
65
+ const ids = selectStaleAccountIds(
66
+ [
67
+ { accountId: "fresh", lastSyncAt: NOW - 5 * 60_000 },
68
+ { accountId: "stale", lastSyncAt: NOW - 30 * 60_000 },
69
+ { accountId: "never", lastSyncAt: undefined },
70
+ ],
71
+ NOW,
72
+ );
73
+ assert.deepEqual(ids, ["stale", "never"]);
74
+ });
75
+
76
+ test("respects an overridden threshold", () => {
77
+ const ids = selectStaleAccountIds(
78
+ [{ accountId: "a-1", lastSyncAt: NOW - 1500 }],
79
+ NOW,
80
+ 1000,
81
+ );
82
+ assert.deepEqual(ids, ["a-1"]);
83
+ });
84
+
85
+ test("returns an empty list for an empty input", () => {
86
+ assert.deepEqual(selectStaleAccountIds([], NOW), []);
87
+ });
88
+
89
+ test("equality on the threshold is treated as fresh", () => {
90
+ // `now - lastSyncAt > thresholdMs` — strict greater-than means an
91
+ // account synced exactly threshold ms ago is still fresh. Pinning
92
+ // this so a future tweak to `>=` is a deliberate decision.
93
+ const ids = selectStaleAccountIds(
94
+ [{ accountId: "a-1", lastSyncAt: NOW - STALENESS_THRESHOLD_MS }],
95
+ NOW,
96
+ );
97
+ assert.deepEqual(ids, []);
98
+ });
99
+ });
100
+
101
+ describe("POLL_INTERVAL_MS", () => {
102
+ test("defaults to 5 minutes when mailboxPollIntervalSeconds is unset in this test run", () => {
103
+ assert.equal(POLL_INTERVAL_MS, 5 * 60 * 1000);
104
+ });
105
+ });
106
+
107
+ describe("hasPollIntervalElapsed", () => {
108
+ test("is false immediately after a poll", () => {
109
+ assert.equal(hasPollIntervalElapsed(NOW, NOW, 60_000), false);
110
+ });
111
+
112
+ test("is false before a full interval has elapsed", () => {
113
+ assert.equal(hasPollIntervalElapsed(NOW, NOW - 59_000, 60_000), false);
114
+ });
115
+
116
+ test("is true exactly at the interval boundary", () => {
117
+ assert.equal(hasPollIntervalElapsed(NOW, NOW - 60_000, 60_000), true);
118
+ });
119
+
120
+ test("is true once the interval has passed", () => {
121
+ assert.equal(hasPollIntervalElapsed(NOW, NOW - 61_000, 60_000), true);
122
+ });
123
+
124
+ test("defaults to POLL_INTERVAL_MS when no interval is passed", () => {
125
+ assert.equal(hasPollIntervalElapsed(NOW, NOW - POLL_INTERVAL_MS - 1), true);
126
+ assert.equal(
127
+ hasPollIntervalElapsed(NOW, NOW - POLL_INTERVAL_MS + 1),
128
+ false,
129
+ );
130
+ });
131
+ });
132
+
133
+ describe("selectPollableAccountIds", () => {
134
+ test("returns every accountId when nothing is in flight", () => {
135
+ const ids = selectPollableAccountIds(["a-1", "a-2"], new Set());
136
+ assert.deepEqual(ids, ["a-1", "a-2"]);
137
+ });
138
+
139
+ test("drops an accountId already in flight (no stacked requests)", () => {
140
+ const ids = selectPollableAccountIds(["a-1", "a-2"], new Set(["a-1"]));
141
+ assert.deepEqual(ids, ["a-2"]);
142
+ });
143
+
144
+ test("returns an empty list when every account is in flight", () => {
145
+ const ids = selectPollableAccountIds(
146
+ ["a-1", "a-2"],
147
+ new Set(["a-1", "a-2"]),
148
+ );
149
+ assert.deepEqual(ids, []);
150
+ });
151
+ });
152
+
153
+ describe("handleBackgroundSyncFailure", () => {
154
+ afterEach(() => {
155
+ __resetFatalError();
156
+ __resetStaleAccountSyncGuard();
157
+ console.warn = originalWarn;
158
+ });
159
+
160
+ const originalWarn = console.warn;
161
+ const silenceWarn = () => {
162
+ console.warn = () => undefined;
163
+ };
164
+
165
+ test("a background-sync 5xx escalates to the fatal overlay", () => {
166
+ silenceWarn();
167
+ const seen: string[] = [];
168
+ subscribeFatalError((fatal) => seen.push(fatal.message));
169
+
170
+ handleBackgroundSyncFailure("a-1", new ApiError("boom", 503));
171
+
172
+ // Fail-fast contract (#1059, rule 2): a 5xx is OUR API broken and always
173
+ // escalates — even from a best-effort background probe. (This reverses the
174
+ // #758 swallow, which let a real 500 vanish silently.)
175
+ assert.deepEqual(seen, ["boom"]);
176
+ });
177
+
178
+ test("a background-sync 4xx (the account's own problem) does NOT escalate", () => {
179
+ silenceWarn();
180
+ let escalated = false;
181
+ subscribeFatalError(() => {
182
+ escalated = true;
183
+ });
184
+
185
+ handleBackgroundSyncFailure("a-1", new ApiError("forbidden", 403));
186
+
187
+ // Non-5xx stays soft — the call site owns it (the Settings "Refresh
188
+ // mailboxes" button is the surfaced path).
189
+ assert.equal(escalated, false);
190
+ });
191
+
192
+ test("a network blip on the background probe does NOT escalate", () => {
193
+ silenceWarn();
194
+ let escalated = false;
195
+ subscribeFatalError(() => {
196
+ escalated = true;
197
+ });
198
+
199
+ handleBackgroundSyncFailure("a-1", new TypeError("Failed to fetch"));
200
+
201
+ assert.equal(escalated, false);
202
+ });
203
+
204
+ test("drops the per-account guard so a later remount can retry", () => {
205
+ silenceWarn();
206
+ __peekStaleAccountSyncGuard(); // touch to ensure import is used
207
+ handleBackgroundSyncFailure("a-1", new ApiError("boom", 503));
208
+
209
+ assert.equal(__peekStaleAccountSyncGuard().has("a-1"), false);
210
+ });
211
+ });