@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,291 @@
1
+ import assert from "node:assert";
2
+ import { beforeEach, describe, mock, test } from "node:test";
3
+
4
+ /**
5
+ * Test harness for useLongPress. Since we can't use React Testing Library,
6
+ * we test the handler logic directly by simulating pointer events and timers.
7
+ */
8
+
9
+ interface MockPointerEvent {
10
+ clientX: number;
11
+ clientY: number;
12
+ }
13
+
14
+ const createPointerEvent = (x: number, y: number): MockPointerEvent => ({
15
+ clientX: x,
16
+ clientY: y,
17
+ });
18
+
19
+ /**
20
+ * Minimal implementation of useLongPress logic for testing.
21
+ * Matches the hook's behavior without React dependencies.
22
+ */
23
+ class LongPressSimulator {
24
+ private timer: ReturnType<typeof setTimeout> | null = null;
25
+ private startPos: { x: number; y: number } | null = null;
26
+ private readonly MOVEMENT_THRESHOLD = 8;
27
+
28
+ constructor(
29
+ private readonly onLongPress: () => void,
30
+ private readonly delayMs: number = 500,
31
+ ) {}
32
+
33
+ onPointerDown(e: MockPointerEvent): void {
34
+ this.clearTimer();
35
+ this.startPos = { x: e.clientX, y: e.clientY };
36
+ this.timer = setTimeout(() => {
37
+ this.onLongPress();
38
+ this.clearTimer();
39
+ }, this.delayMs);
40
+ }
41
+
42
+ onPointerMove(e: MockPointerEvent): void {
43
+ if (!this.startPos) return;
44
+
45
+ const dx = e.clientX - this.startPos.x;
46
+ const dy = e.clientY - this.startPos.y;
47
+ const distance = Math.sqrt(dx * dx + dy * dy);
48
+
49
+ if (distance > this.MOVEMENT_THRESHOLD) {
50
+ this.clearTimer();
51
+ }
52
+ }
53
+
54
+ onPointerUp(): void {
55
+ this.clearTimer();
56
+ }
57
+
58
+ onPointerCancel(): void {
59
+ this.clearTimer();
60
+ }
61
+
62
+ private clearTimer(): void {
63
+ if (this.timer) {
64
+ clearTimeout(this.timer);
65
+ this.timer = null;
66
+ }
67
+ this.startPos = null;
68
+ }
69
+
70
+ /** Test helper: check if timer is active */
71
+ hasActiveTimer(): boolean {
72
+ return this.timer !== null;
73
+ }
74
+
75
+ /** Test helper: cleanup */
76
+ cleanup(): void {
77
+ this.clearTimer();
78
+ }
79
+ }
80
+
81
+ describe("useLongPress", () => {
82
+ beforeEach(() => {
83
+ // Reset any timers between tests
84
+ mock.restoreAll();
85
+ });
86
+
87
+ test("fires callback after delay with no movement", async () => {
88
+ const onLongPress = mock.fn();
89
+ const sim = new LongPressSimulator(onLongPress, 500);
90
+
91
+ sim.onPointerDown(createPointerEvent(100, 100));
92
+
93
+ // Wait for the delay
94
+ await new Promise((resolve) => setTimeout(resolve, 550));
95
+
96
+ assert.strictEqual(onLongPress.mock.calls.length, 1);
97
+ sim.cleanup();
98
+ });
99
+
100
+ test("does not fire if pointer moves more than 8px", async () => {
101
+ const onLongPress = mock.fn();
102
+ const sim = new LongPressSimulator(onLongPress, 500);
103
+
104
+ sim.onPointerDown(createPointerEvent(100, 100));
105
+
106
+ // Move 10 pixels (more than threshold of 8)
107
+ sim.onPointerMove(createPointerEvent(110, 100));
108
+
109
+ // Wait beyond the delay
110
+ await new Promise((resolve) => setTimeout(resolve, 550));
111
+
112
+ assert.strictEqual(
113
+ onLongPress.mock.calls.length,
114
+ 0,
115
+ "callback should not fire after moving > threshold",
116
+ );
117
+ sim.cleanup();
118
+ });
119
+
120
+ test("does not fire on early pointerup", async () => {
121
+ const onLongPress = mock.fn();
122
+ const sim = new LongPressSimulator(onLongPress, 500);
123
+
124
+ sim.onPointerDown(createPointerEvent(100, 100));
125
+
126
+ // Release before delay completes
127
+ await new Promise((resolve) => setTimeout(resolve, 100));
128
+ sim.onPointerUp();
129
+
130
+ // Wait beyond the original delay
131
+ await new Promise((resolve) => setTimeout(resolve, 500));
132
+
133
+ assert.strictEqual(
134
+ onLongPress.mock.calls.length,
135
+ 0,
136
+ "callback should not fire after early pointerup",
137
+ );
138
+ sim.cleanup();
139
+ });
140
+
141
+ test("does not fire on pointercancel", async () => {
142
+ const onLongPress = mock.fn();
143
+ const sim = new LongPressSimulator(onLongPress, 500);
144
+
145
+ sim.onPointerDown(createPointerEvent(100, 100));
146
+
147
+ // Cancel before delay completes
148
+ await new Promise((resolve) => setTimeout(resolve, 100));
149
+ sim.onPointerCancel();
150
+
151
+ // Wait beyond the original delay
152
+ await new Promise((resolve) => setTimeout(resolve, 500));
153
+
154
+ assert.strictEqual(
155
+ onLongPress.mock.calls.length,
156
+ 0,
157
+ "callback should not fire after pointercancel",
158
+ );
159
+ sim.cleanup();
160
+ });
161
+
162
+ test("respects custom delayMs", async () => {
163
+ const onLongPress = mock.fn();
164
+ const customDelay = 200;
165
+ const sim = new LongPressSimulator(onLongPress, customDelay);
166
+
167
+ sim.onPointerDown(createPointerEvent(100, 100));
168
+
169
+ // Wait less than custom delay
170
+ await new Promise((resolve) => setTimeout(resolve, 150));
171
+ assert.strictEqual(
172
+ onLongPress.mock.calls.length,
173
+ 0,
174
+ "should not fire before custom delay",
175
+ );
176
+
177
+ // Wait past custom delay
178
+ await new Promise((resolve) => setTimeout(resolve, 100));
179
+ assert.strictEqual(
180
+ onLongPress.mock.calls.length,
181
+ 1,
182
+ "should fire after custom delay",
183
+ );
184
+ sim.cleanup();
185
+ });
186
+
187
+ test("allows movement within 8px threshold", async () => {
188
+ const onLongPress = mock.fn();
189
+ const sim = new LongPressSimulator(onLongPress, 500);
190
+
191
+ sim.onPointerDown(createPointerEvent(100, 100));
192
+
193
+ // Move 7 pixels (within threshold)
194
+ sim.onPointerMove(createPointerEvent(107, 100));
195
+
196
+ // Wait for the delay
197
+ await new Promise((resolve) => setTimeout(resolve, 550));
198
+
199
+ assert.strictEqual(
200
+ onLongPress.mock.calls.length,
201
+ 1,
202
+ "callback should fire when movement is within threshold",
203
+ );
204
+ sim.cleanup();
205
+ });
206
+
207
+ test("calculates diagonal movement correctly", async () => {
208
+ const onLongPress = mock.fn();
209
+ const sim = new LongPressSimulator(onLongPress, 500);
210
+
211
+ sim.onPointerDown(createPointerEvent(100, 100));
212
+
213
+ // Move 6px in both x and y (diagonal distance ~8.48px > 8px threshold)
214
+ sim.onPointerMove(createPointerEvent(106, 106));
215
+
216
+ // Wait beyond the delay
217
+ await new Promise((resolve) => setTimeout(resolve, 550));
218
+
219
+ assert.strictEqual(
220
+ onLongPress.mock.calls.length,
221
+ 0,
222
+ "callback should not fire when diagonal movement exceeds threshold",
223
+ );
224
+ sim.cleanup();
225
+ });
226
+
227
+ test("ignores pointer move before pointer down", async () => {
228
+ const onLongPress = mock.fn();
229
+ const sim = new LongPressSimulator(onLongPress, 500);
230
+
231
+ // Move without pressing down first
232
+ sim.onPointerMove(createPointerEvent(200, 200));
233
+
234
+ sim.onPointerDown(createPointerEvent(100, 100));
235
+
236
+ // Wait for the delay
237
+ await new Promise((resolve) => setTimeout(resolve, 550));
238
+
239
+ assert.strictEqual(
240
+ onLongPress.mock.calls.length,
241
+ 1,
242
+ "callback should fire - earlier move should be ignored",
243
+ );
244
+ sim.cleanup();
245
+ });
246
+
247
+ test("resets on new pointer down", async () => {
248
+ const onLongPress = mock.fn();
249
+ const sim = new LongPressSimulator(onLongPress, 500);
250
+
251
+ // First press
252
+ sim.onPointerDown(createPointerEvent(100, 100));
253
+
254
+ // Wait a bit
255
+ await new Promise((resolve) => setTimeout(resolve, 200));
256
+
257
+ // New press at different location (should reset timer)
258
+ sim.onPointerDown(createPointerEvent(200, 200));
259
+
260
+ // Wait for delay from second press
261
+ await new Promise((resolve) => setTimeout(resolve, 550));
262
+
263
+ // Should only fire once (from second press)
264
+ assert.strictEqual(
265
+ onLongPress.mock.calls.length,
266
+ 1,
267
+ "callback should fire once from second press",
268
+ );
269
+ sim.cleanup();
270
+ });
271
+
272
+ test("exact 8px movement does not cancel", async () => {
273
+ const onLongPress = mock.fn();
274
+ const sim = new LongPressSimulator(onLongPress, 500);
275
+
276
+ sim.onPointerDown(createPointerEvent(100, 100));
277
+
278
+ // Move exactly 8 pixels
279
+ sim.onPointerMove(createPointerEvent(108, 100));
280
+
281
+ // Wait for the delay
282
+ await new Promise((resolve) => setTimeout(resolve, 550));
283
+
284
+ assert.strictEqual(
285
+ onLongPress.mock.calls.length,
286
+ 1,
287
+ "callback should fire - movement exactly at threshold is allowed",
288
+ );
289
+ sim.cleanup();
290
+ });
291
+ });
@@ -0,0 +1,87 @@
1
+ import { useCallback, useRef } from "react";
2
+
3
+ interface UseLongPressOptions {
4
+ onLongPress: () => void;
5
+ delayMs?: number;
6
+ }
7
+
8
+ interface LongPressHandlers {
9
+ onPointerDown: (e: PointerEvent | React.PointerEvent) => void;
10
+ onPointerMove: (e: PointerEvent | React.PointerEvent) => void;
11
+ onPointerUp: () => void;
12
+ onPointerCancel: () => void;
13
+ }
14
+
15
+ const MOVEMENT_THRESHOLD = 8; // pixels
16
+
17
+ /**
18
+ * Hook for detecting long-press gestures on touch/pointer devices.
19
+ *
20
+ * @param onLongPress - Callback fired after the delay if the pointer hasn't moved
21
+ * @param delayMs - How long to wait before firing (default 500ms)
22
+ *
23
+ * @returns Pointer event handlers to spread onto your element
24
+ *
25
+ * @example
26
+ * const { handlers } = useLongPress({ onLongPress: () => console.log('long press!') });
27
+ * return <div {...handlers}>Press and hold me</div>;
28
+ */
29
+ export const useLongPress = ({
30
+ onLongPress,
31
+ delayMs = 500,
32
+ }: UseLongPressOptions): { handlers: LongPressHandlers } => {
33
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
34
+ const startPosRef = useRef<{ x: number; y: number } | null>(null);
35
+
36
+ const clearTimer = useCallback(() => {
37
+ if (timerRef.current) {
38
+ clearTimeout(timerRef.current);
39
+ timerRef.current = null;
40
+ }
41
+ startPosRef.current = null;
42
+ }, []);
43
+
44
+ const onPointerDown = useCallback(
45
+ (e: PointerEvent | React.PointerEvent) => {
46
+ clearTimer();
47
+ startPosRef.current = { x: e.clientX, y: e.clientY };
48
+ timerRef.current = setTimeout(() => {
49
+ onLongPress();
50
+ clearTimer();
51
+ }, delayMs);
52
+ },
53
+ [onLongPress, delayMs, clearTimer],
54
+ );
55
+
56
+ const onPointerMove = useCallback(
57
+ (e: PointerEvent | React.PointerEvent) => {
58
+ if (!startPosRef.current) return;
59
+
60
+ const dx = e.clientX - startPosRef.current.x;
61
+ const dy = e.clientY - startPosRef.current.y;
62
+ const distance = Math.sqrt(dx * dx + dy * dy);
63
+
64
+ if (distance > MOVEMENT_THRESHOLD) {
65
+ clearTimer();
66
+ }
67
+ },
68
+ [clearTimer],
69
+ );
70
+
71
+ const onPointerUp = useCallback(() => {
72
+ clearTimer();
73
+ }, [clearTimer]);
74
+
75
+ const onPointerCancel = useCallback(() => {
76
+ clearTimer();
77
+ }, [clearTimer]);
78
+
79
+ return {
80
+ handlers: {
81
+ onPointerDown,
82
+ onPointerMove,
83
+ onPointerUp,
84
+ onPointerCancel,
85
+ },
86
+ };
87
+ };
@@ -0,0 +1,62 @@
1
+ import {
2
+ configOperationsGetConfigOptions,
3
+ mailboxOperationsListMailboxesOptions,
4
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
+ import { useQueries, useQuery } from "@tanstack/react-query";
6
+ import { useMemo } from "react";
7
+
8
+ interface MailboxAccountResolution {
9
+ accountId: string | undefined;
10
+ isLoading: boolean;
11
+ }
12
+
13
+ /**
14
+ * Resolve the owning account for a single mailbox by id.
15
+ *
16
+ * The mailbox-list query is keyed by accountId, so we fan out to every
17
+ * configured account's mailbox list (cached forever, see sidebar) and find
18
+ * the one containing this mailboxId. Returns `accountId: undefined` while
19
+ * the lookup is still loading or if the mailbox isn't found in any account.
20
+ *
21
+ * Used by per-message Move actions where the `MessageActionMenu` only knows
22
+ * the mailboxId — the picker must scope to the correct account or the
23
+ * destination list would mix folders from unrelated accounts.
24
+ */
25
+ export const useMailboxAccount = (
26
+ mailboxId: string | undefined,
27
+ ): MailboxAccountResolution => {
28
+ const { data: config, isLoading: isConfigLoading } = useQuery({
29
+ ...configOperationsGetConfigOptions(),
30
+ staleTime: Infinity,
31
+ });
32
+
33
+ const accountIds = useMemo(
34
+ () => (config?.accounts ?? []).map((account) => account.accountId),
35
+ [config?.accounts],
36
+ );
37
+
38
+ const mailboxQueries = useQueries({
39
+ queries: accountIds.map((accountId) => ({
40
+ ...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
41
+ staleTime: Infinity,
42
+ enabled: !!mailboxId,
43
+ })),
44
+ });
45
+
46
+ const isLoading =
47
+ isConfigLoading || mailboxQueries.some((query) => query.isLoading);
48
+
49
+ const accountId = useMemo(() => {
50
+ if (!mailboxId) return undefined;
51
+ for (let i = 0; i < accountIds.length; i++) {
52
+ const result = mailboxQueries[i];
53
+ const items = result.data?.items ?? [];
54
+ if (items.some((mailbox) => mailbox.mailboxId === mailboxId)) {
55
+ return accountIds[i];
56
+ }
57
+ }
58
+ return undefined;
59
+ }, [mailboxId, accountIds, mailboxQueries]);
60
+
61
+ return { accountId, isLoading };
62
+ };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Fans out the per-account mailbox-list query (mirrors `MailSidebarAdapter` /
3
+ * `useMailboxAccount` — cached forever, react-query dedupes the identical
4
+ * query key across call sites) and reduces it to the name index `in:` tokens
5
+ * resolve against (#428 follow-up, see doc/design/flows/06-search.md).
6
+ */
7
+ import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
8
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
9
+ import { useQueries } from "@tanstack/react-query";
10
+ import { useMemo } from "react";
11
+ import { buildMailboxNameIndex } from "@/lib/search-token-index";
12
+
13
+ export function useMailboxNameIndex(
14
+ accounts: RemitImapAccountResponse[],
15
+ ): ReadonlyMap<string, string> {
16
+ const mailboxQueries = useQueries({
17
+ queries: accounts.map((account) => ({
18
+ ...mailboxOperationsListMailboxesOptions({
19
+ path: { accountId: account.accountId },
20
+ }),
21
+ staleTime: Infinity,
22
+ })),
23
+ });
24
+
25
+ return useMemo(
26
+ () =>
27
+ buildMailboxNameIndex(
28
+ mailboxQueries.map((query) => query.data?.items ?? []),
29
+ ),
30
+ [mailboxQueries],
31
+ );
32
+ }
@@ -0,0 +1,165 @@
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 { selectMessagesToMarkRead } from "./useMarkAsRead.js";
5
+
6
+ const make = (
7
+ overrides: Partial<RemitImapThreadMessageResponse> & {
8
+ messageId: string;
9
+ threadMessageId: string;
10
+ isRead: boolean;
11
+ },
12
+ ): RemitImapThreadMessageResponse =>
13
+ ({
14
+ threadId: "t1",
15
+ mailboxId: "mb1",
16
+ subject: "s",
17
+ fromName: "n",
18
+ fromEmail: "e",
19
+ sentDate: "2025-01-01T00:00:00Z",
20
+ snippet: "",
21
+ hasAttachment: false,
22
+ hasStars: false,
23
+ ...overrides,
24
+ }) as RemitImapThreadMessageResponse;
25
+
26
+ describe("selectMessagesToMarkRead", () => {
27
+ test("returns the unread expanded messages", () => {
28
+ const messages = [
29
+ make({ messageId: "m1", threadMessageId: "tm1", isRead: false }),
30
+ make({ messageId: "m2", threadMessageId: "tm2", isRead: true }),
31
+ make({ messageId: "m3", threadMessageId: "tm3", isRead: false }),
32
+ ];
33
+ const expanded = new Set(["tm1", "tm3"]);
34
+ const got = selectMessagesToMarkRead(
35
+ messages,
36
+ expanded,
37
+ new Set(),
38
+ new Set(),
39
+ );
40
+ assert.deepStrictEqual(got, ["m1", "m3"]);
41
+ });
42
+
43
+ test("skips already-read messages even when expanded", () => {
44
+ const messages = [
45
+ make({ messageId: "m1", threadMessageId: "tm1", isRead: true }),
46
+ ];
47
+ const expanded = new Set(["tm1"]);
48
+ const got = selectMessagesToMarkRead(
49
+ messages,
50
+ expanded,
51
+ new Set(),
52
+ new Set(),
53
+ );
54
+ assert.deepStrictEqual(got, []);
55
+ });
56
+
57
+ test("skips collapsed messages even when unread", () => {
58
+ const messages = [
59
+ make({ messageId: "m1", threadMessageId: "tm1", isRead: false }),
60
+ make({ messageId: "m2", threadMessageId: "tm2", isRead: false }),
61
+ ];
62
+ const expanded = new Set(["tm1"]);
63
+ const got = selectMessagesToMarkRead(
64
+ messages,
65
+ expanded,
66
+ new Set(),
67
+ new Set(),
68
+ );
69
+ assert.deepStrictEqual(got, ["m1"]);
70
+ });
71
+
72
+ test("skips messages already marked in this session", () => {
73
+ const messages = [
74
+ make({ messageId: "m1", threadMessageId: "tm1", isRead: false }),
75
+ ];
76
+ const expanded = new Set(["tm1"]);
77
+ const got = selectMessagesToMarkRead(
78
+ messages,
79
+ expanded,
80
+ new Set(["m1"]),
81
+ new Set(),
82
+ );
83
+ assert.deepStrictEqual(got, []);
84
+ });
85
+
86
+ test("skips messages with an in-flight mutation", () => {
87
+ const messages = [
88
+ make({ messageId: "m1", threadMessageId: "tm1", isRead: false }),
89
+ ];
90
+ const expanded = new Set(["tm1"]);
91
+ const got = selectMessagesToMarkRead(
92
+ messages,
93
+ expanded,
94
+ new Set(),
95
+ new Set(["m1"]),
96
+ );
97
+ assert.deepStrictEqual(got, []);
98
+ });
99
+
100
+ test("returns empty list when nothing is expanded", () => {
101
+ const messages = [
102
+ make({ messageId: "m1", threadMessageId: "tm1", isRead: false }),
103
+ make({ messageId: "m2", threadMessageId: "tm2", isRead: false }),
104
+ ];
105
+ const got = selectMessagesToMarkRead(
106
+ messages,
107
+ new Set(),
108
+ new Set(),
109
+ new Set(),
110
+ );
111
+ assert.deepStrictEqual(got, []);
112
+ });
113
+
114
+ test("does not require all unread messages to be expanded", () => {
115
+ // Regression: the previous policy gated firing on every unread
116
+ // message being expanded, which meant most opens silently never
117
+ // produced a mutation. The newest message being expanded must be
118
+ // enough to mark it read.
119
+ const messages = [
120
+ make({ messageId: "m1", threadMessageId: "tm1", isRead: false }),
121
+ make({ messageId: "m2", threadMessageId: "tm2", isRead: false }),
122
+ make({ messageId: "m3", threadMessageId: "tm3", isRead: false }),
123
+ ];
124
+ const expanded = new Set(["tm1"]);
125
+ const got = selectMessagesToMarkRead(
126
+ messages,
127
+ expanded,
128
+ new Set(),
129
+ new Set(),
130
+ );
131
+ assert.deepStrictEqual(got, ["m1"]);
132
+ });
133
+
134
+ test("marks an older selected message when newest is already read (#683)", () => {
135
+ // Regression: ConversationView opens with order:"desc" so messages[0] is
136
+ // the newest. When the user clicks a thread row whose representative
137
+ // message is an older unread message (not the newest), only that older
138
+ // message must be marked. The newest is already read and must be skipped.
139
+ const messages = [
140
+ // Newest — already read (messages[0] in desc order)
141
+ make({
142
+ messageId: "m-newest",
143
+ threadMessageId: "tm-newest",
144
+ isRead: true,
145
+ }),
146
+ // Older — unread, the one the user clicked in the thread list
147
+ make({
148
+ messageId: "m-older",
149
+ threadMessageId: "tm-older",
150
+ isRead: false,
151
+ }),
152
+ ];
153
+ // ConversationView now expands both messages[0] AND the selected message.
154
+ // Here the selected message is the older one, so both are expanded.
155
+ const expanded = new Set(["tm-newest", "tm-older"]);
156
+ const got = selectMessagesToMarkRead(
157
+ messages,
158
+ expanded,
159
+ new Set(),
160
+ new Set(),
161
+ );
162
+ // Only the unread older message should be marked — the newest is already read.
163
+ assert.deepStrictEqual(got, ["m-older"]);
164
+ });
165
+ });