@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,161 @@
1
+ import {
2
+ addressDetailOperationsUpdateAddressMutation,
3
+ messageOperationsDescribeMessageQueryKey,
4
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
+ import type {
6
+ RemitImapAddressFlags,
7
+ RemitImapDescribeMessageResponse,
8
+ RemitImapEnvelopeAddressResponse,
9
+ RemitImapTrustedFlag,
10
+ } from "@remit/api-http-client/types.gen.ts";
11
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
12
+ import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
13
+ import { buildMutationErrorBanner } from "@/components/ui/error-banners";
14
+
15
+ interface UseToggleTrustedOptions {
16
+ messageId: string;
17
+ }
18
+
19
+ interface SnapshotEntry {
20
+ queryKey: readonly unknown[];
21
+ data: RemitImapDescribeMessageResponse;
22
+ }
23
+
24
+ interface ToggleTrustedContext {
25
+ describePrefix: readonly unknown[];
26
+ previous: SnapshotEntry[];
27
+ }
28
+
29
+ export const buildTrustedFlag = (
30
+ nextTrusted: boolean,
31
+ now: number = Date.now(),
32
+ ): RemitImapTrustedFlag => ({
33
+ value: nextTrusted,
34
+ setAt: now,
35
+ });
36
+
37
+ export const patchEnvelopeAddress = (
38
+ addr: RemitImapEnvelopeAddressResponse,
39
+ addressId: string,
40
+ nextTrusted: boolean,
41
+ now?: number,
42
+ ): RemitImapEnvelopeAddressResponse => {
43
+ if (addr.addressId !== addressId) return addr;
44
+ const flags: RemitImapAddressFlags = { ...(addr.flags ?? {}) };
45
+ if (nextTrusted) {
46
+ flags.trusted = buildTrustedFlag(true, now);
47
+ } else {
48
+ delete flags.trusted;
49
+ }
50
+ return { ...addr, flags };
51
+ };
52
+
53
+ export const patchDescribeMessage = (
54
+ data: RemitImapDescribeMessageResponse,
55
+ addressId: string,
56
+ nextTrusted: boolean,
57
+ now?: number,
58
+ ): RemitImapDescribeMessageResponse => {
59
+ const map = (list: RemitImapEnvelopeAddressResponse[]) =>
60
+ list.map((addr) => patchEnvelopeAddress(addr, addressId, nextTrusted, now));
61
+ return {
62
+ ...data,
63
+ envelope: {
64
+ ...data.envelope,
65
+ from: map(data.envelope.from),
66
+ to: map(data.envelope.to),
67
+ cc: map(data.envelope.cc),
68
+ bcc: map(data.envelope.bcc),
69
+ replyTo: map(data.envelope.replyTo),
70
+ },
71
+ };
72
+ };
73
+
74
+ /**
75
+ * Toggle the per-sender `trusted` flag for an Address. Mirrors the optimistic-
76
+ * update + rollback pattern from `useToggleStar`: we patch every cached
77
+ * `describeMessage` response that mentions this address so the UI flips
78
+ * instantly, then roll back on error per the project's "never hide failure"
79
+ * rule. The failure is surfaced reliably here in `onError` (banner + rollback),
80
+ * not via a fragile consumer effect; a fatal 5xx additionally escalates through
81
+ * the global MutationCache.onError sink.
82
+ */
83
+ export const useToggleTrusted = ({ messageId }: UseToggleTrustedOptions) => {
84
+ const queryClient = useQueryClient();
85
+ const { pushError } = useErrorBanners();
86
+
87
+ const { mutate, isPending, error, reset } = useMutation({
88
+ ...addressDetailOperationsUpdateAddressMutation(),
89
+ onMutate: async (vars): Promise<ToggleTrustedContext> => {
90
+ const addressId = vars.path.addressId;
91
+ const trustedPatch = vars.body.flags?.trusted;
92
+ const nextTrusted = trustedPatch?.value === true;
93
+
94
+ const describePrefix = messageOperationsDescribeMessageQueryKey({
95
+ path: { messageId },
96
+ });
97
+
98
+ await queryClient.cancelQueries({ queryKey: describePrefix });
99
+
100
+ const previous = queryClient
101
+ .getQueriesData<RemitImapDescribeMessageResponse>({
102
+ queryKey: describePrefix,
103
+ })
104
+ .filter(
105
+ (
106
+ entry,
107
+ ): entry is [readonly unknown[], RemitImapDescribeMessageResponse] =>
108
+ entry[1] !== undefined,
109
+ )
110
+ .map(([queryKey, data]) => ({ queryKey, data }));
111
+
112
+ queryClient.setQueriesData<RemitImapDescribeMessageResponse>(
113
+ { queryKey: describePrefix },
114
+ (old) =>
115
+ old ? patchDescribeMessage(old, addressId, nextTrusted) : old,
116
+ );
117
+
118
+ return { describePrefix, previous };
119
+ },
120
+ onError: (err, _vars, context) => {
121
+ if (context) {
122
+ for (const entry of context.previous) {
123
+ queryClient.setQueryData(entry.queryKey, entry.data);
124
+ }
125
+ }
126
+ pushError(
127
+ buildMutationErrorBanner(
128
+ "Couldn't trust sender",
129
+ "The change wasn't saved.",
130
+ err,
131
+ ),
132
+ );
133
+ },
134
+ onSettled: (_data, _err, _vars, context) => {
135
+ if (!context) return;
136
+ queryClient.invalidateQueries({ queryKey: context.describePrefix });
137
+ },
138
+ });
139
+
140
+ const toggleTrusted = (addressId: string, currentlyTrusted: boolean) => {
141
+ const next = !currentlyTrusted;
142
+ // Always send a TrustedFlag object (value: true|false). The schema
143
+ // nominally accepts `trusted: null` to remove the flag, but our
144
+ // openapi-backend validator doesn't honour `nullable: true` on a
145
+ // schema wrapped in `allOf: [$ref]` (which is how TypeSpec emits
146
+ // `TrustedFlag | null` for OAS 3.0), so the server rejected untrust
147
+ // requests as invalid. `{ value: false }` reads as "not trusted"
148
+ // everywhere (every check is `flags?.trusted?.value === true`) and
149
+ // also leaves an audit timestamp on the flag.
150
+ mutate({
151
+ path: { addressId },
152
+ body: {
153
+ flags: {
154
+ trusted: buildTrustedFlag(next),
155
+ },
156
+ },
157
+ });
158
+ };
159
+
160
+ return { toggleTrusted, isPending, error, reset };
161
+ };
@@ -0,0 +1,103 @@
1
+ import { useEffect, useRef } from "react";
2
+ import type { TriageAction } from "@/lib/keymap";
3
+ import {
4
+ dispatchKey,
5
+ isEditableTarget,
6
+ type SequencePrefix,
7
+ } from "@/lib/keymap-dispatch";
8
+
9
+ /** Map of action → handler. Omitted actions are inert (no-op). */
10
+ export type TriageHandlers = Partial<Record<TriageAction, () => void>>;
11
+
12
+ interface UseTriageKeyboardOptions {
13
+ handlers: TriageHandlers;
14
+ /** Disable the whole layer (e.g. a blocking modal owns the keyboard). */
15
+ enabled?: boolean;
16
+ /**
17
+ * Reset window (ms) for a pending `g …` sequence prefix. After this with no
18
+ * second key, the prefix is dropped. ~1s per the spec.
19
+ */
20
+ sequenceTimeoutMs?: number;
21
+ }
22
+
23
+ /**
24
+ * Global keydown handler for the triage layer's VERBS (#429). Routes keystrokes
25
+ * through the pure {@link dispatchKey} core to the supplied handler table,
26
+ * staying fully inert while focus is in an editable surface (input/textarea/CE;
27
+ * even Esc is left to the focused field's own handler) and carrying the `g …`
28
+ * go-to sequence prefix across keystrokes with a timeout.
29
+ *
30
+ * Routing is intentionally NOT fully unified yet: this hook owns the action
31
+ * verbs + go-to keys, while the list-local navigation keys (j/k/x/Enter/d,
32
+ * Shift+arrow, Delete/Backspace) are still routed by MessageList's legacy
33
+ * `useKeyboardNavigation`, `/` by SearchBar, and `?` by the mail layout — three
34
+ * separate window listeners, each with its own (consistent) input-guard. The
35
+ * `@/lib/keymap` module is the single source of truth for the DISPLAYED
36
+ * bindings (the `?` overlay + tooltips), but not yet for routing. Consolidating
37
+ * every binding through `dispatchKey` is a follow-up; see #429.
38
+ *
39
+ * Per-action targeting (focused row vs selection) and the actual mutations live
40
+ * in the handlers the caller passes in — this hook only dispatches.
41
+ */
42
+ export function useTriageKeyboard({
43
+ handlers,
44
+ enabled = true,
45
+ sequenceTimeoutMs = 1000,
46
+ }: UseTriageKeyboardOptions): void {
47
+ // Latest handlers without re-subscribing the listener every render.
48
+ const handlersRef = useRef(handlers);
49
+ handlersRef.current = handlers;
50
+
51
+ const prefixRef = useRef<SequencePrefix>(null);
52
+ const prefixTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
53
+
54
+ useEffect(() => {
55
+ if (!enabled) return;
56
+
57
+ const clearPrefixTimer = () => {
58
+ if (prefixTimerRef.current !== null) {
59
+ clearTimeout(prefixTimerRef.current);
60
+ prefixTimerRef.current = null;
61
+ }
62
+ };
63
+
64
+ const onKeyDown = (event: KeyboardEvent) => {
65
+ const result = dispatchKey(
66
+ {
67
+ key: event.key,
68
+ shiftKey: event.shiftKey,
69
+ metaKey: event.metaKey,
70
+ ctrlKey: event.ctrlKey,
71
+ altKey: event.altKey,
72
+ inEditable: isEditableTarget(event.target),
73
+ },
74
+ prefixRef.current,
75
+ );
76
+
77
+ // Update the pending prefix and (re)arm / clear its reset timer.
78
+ clearPrefixTimer();
79
+ prefixRef.current = result.nextPrefix;
80
+ if (result.nextPrefix !== null) {
81
+ prefixTimerRef.current = setTimeout(() => {
82
+ prefixRef.current = null;
83
+ prefixTimerRef.current = null;
84
+ }, sequenceTimeoutMs);
85
+ }
86
+
87
+ if (result.action === null) return;
88
+
89
+ const handler = handlersRef.current[result.action];
90
+ if (!handler) return;
91
+
92
+ if (result.preventDefault) event.preventDefault();
93
+ handler();
94
+ };
95
+
96
+ window.addEventListener("keydown", onKeyDown);
97
+ return () => {
98
+ window.removeEventListener("keydown", onKeyDown);
99
+ clearPrefixTimer();
100
+ prefixRef.current = null;
101
+ };
102
+ }, [enabled, sequenceTimeoutMs]);
103
+ }
@@ -0,0 +1,24 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
4
+ import { buildMailboxListKey } from "./useTriggerSync.js";
5
+
6
+ describe("buildMailboxListKey", () => {
7
+ test("matches the generated SDK query key for the same accountId", () => {
8
+ // On success the trigger-sync mutation invalidates this key — if it
9
+ // drifts from the generated SDK key, the freshly-synced mailbox rows
10
+ // will not be re-fetched. Pin the contract.
11
+ const accountId = "acc-1234";
12
+ const got = buildMailboxListKey(accountId);
13
+ const want = mailboxOperationsListMailboxesQueryKey({
14
+ path: { accountId },
15
+ });
16
+ assert.deepEqual(got, want);
17
+ });
18
+
19
+ test("returns a different key for a different accountId", () => {
20
+ const a = buildMailboxListKey("acc-1");
21
+ const b = buildMailboxListKey("acc-2");
22
+ assert.notDeepEqual(a, b);
23
+ });
24
+ });
@@ -0,0 +1,58 @@
1
+ import {
2
+ mailboxOperationsListMailboxesQueryKey,
3
+ syncOperationsTriggerSyncMutation,
4
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
6
+ import { useTelemetry } from "@/lib/telemetry-context";
7
+
8
+ interface UseTriggerSyncResult {
9
+ trigger: () => void;
10
+ triggerAsync: () => Promise<unknown>;
11
+ isPending: boolean;
12
+ error: Error | null;
13
+ reset: () => void;
14
+ }
15
+
16
+ /**
17
+ * Build the TanStack query key for the mailbox-list query owned by the
18
+ * given account. Extracted from the hook so the staleness/invalidation
19
+ * contract can be unit-tested without rendering React.
20
+ */
21
+ export const buildMailboxListKey = (accountId: string) =>
22
+ mailboxOperationsListMailboxesQueryKey({ path: { accountId } });
23
+
24
+ /**
25
+ * Trigger a server-side mailbox-list sync for an account.
26
+ *
27
+ * Wraps the generated `syncOperationsTriggerSync` mutation. The backend
28
+ * enqueues a SYNC_MAILBOXES SQS event and the worker picks it up; the
29
+ * mutation resolves as soon as the enqueue ack returns, not when the
30
+ * sync itself finishes. We invalidate the account's mailboxes query on
31
+ * success so the freshly-synced rows are re-fetched once the worker
32
+ * writes them — TanStack will pick the new data up via its background
33
+ * refetch on the next render.
34
+ */
35
+ export const useTriggerSync = (accountId: string): UseTriggerSyncResult => {
36
+ const queryClient = useQueryClient();
37
+ const telemetry = useTelemetry();
38
+
39
+ const mutation = useMutation({
40
+ ...syncOperationsTriggerSyncMutation(),
41
+ onSuccess: () => {
42
+ telemetry.recordEvent("sync.triggered");
43
+ queryClient.invalidateQueries({
44
+ queryKey: buildMailboxListKey(accountId),
45
+ });
46
+ },
47
+ });
48
+
49
+ return {
50
+ trigger: () => {
51
+ mutation.mutate({ path: { accountId } });
52
+ },
53
+ triggerAsync: () => mutation.mutateAsync({ path: { accountId } }),
54
+ isPending: mutation.isPending,
55
+ error: mutation.error,
56
+ reset: mutation.reset,
57
+ };
58
+ };
@@ -0,0 +1,125 @@
1
+ import {
2
+ addressDetailOperationsUpdateAddressMutation,
3
+ addressOperationsSearchAddressesQueryKey,
4
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
+ import type {
6
+ AddressOperationsSearchAddressesResponse,
7
+ RemitImapAddressFlags,
8
+ RemitImapUpdateAddressFlagsInput,
9
+ } from "@remit/api-http-client/types.gen.ts";
10
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
11
+ import { useCallback } from "react";
12
+ import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
13
+ import { formatErrorDetail } from "@/components/ui/error-banners";
14
+ import { reportFatalError } from "@/lib/fatal-error";
15
+
16
+ interface UseUpdateAddressFlagsOptions {
17
+ addressId: string | undefined;
18
+ /** The normalizedEmail of the sender — used to key the address search cache. */
19
+ senderEmail: string | undefined;
20
+ }
21
+
22
+ interface MutationContext {
23
+ previous: AddressOperationsSearchAddressesResponse | undefined;
24
+ }
25
+
26
+ /**
27
+ * Apply a flag-update patch onto an existing AddressFlags object. Each key in
28
+ * `patch` is a flag update object (set) or `null` (remove). Mirrors the
29
+ * server-side merge semantics so the optimistic cache matches what the API
30
+ * returns. Typed loosely (the per-key flag value types differ — boolean for
31
+ * most, a category enum for `category`) and re-narrowed at the return.
32
+ */
33
+ function applyFlagPatch(
34
+ current: RemitImapAddressFlags | undefined,
35
+ patch: RemitImapUpdateAddressFlagsInput,
36
+ ): RemitImapAddressFlags {
37
+ const next: Record<string, unknown> = { ...(current ?? {}) };
38
+ for (const [key, update] of Object.entries(patch)) {
39
+ if (update == null) {
40
+ delete next[key];
41
+ } else {
42
+ // Each flag is `{ value, setAt? }`. Stamp setAt so the optimistic
43
+ // shape matches what the server writes back.
44
+ next[key] = { ...update, setAt: update.setAt ?? Date.now() };
45
+ }
46
+ }
47
+ return next as RemitImapAddressFlags;
48
+ }
49
+
50
+ /**
51
+ * Optimistic PATCH for per-sender flags (VIP / Mute / Block / Unsubscribe).
52
+ * Mirrors the `useToggleTrusted` pattern: patch the cached
53
+ * address-search response so the toggle flips instantly, roll back on error,
54
+ * and invalidate on settle to reconcile with the server.
55
+ */
56
+ export function useUpdateAddressFlags({
57
+ addressId,
58
+ senderEmail,
59
+ }: UseUpdateAddressFlagsOptions) {
60
+ const queryClient = useQueryClient();
61
+ const { pushError } = useErrorBanners();
62
+
63
+ const addressCacheKey = addressOperationsSearchAddressesQueryKey({
64
+ query: { q: senderEmail ?? "", limit: 1 },
65
+ });
66
+
67
+ const { mutate, isPending } = useMutation({
68
+ ...addressDetailOperationsUpdateAddressMutation(),
69
+ onMutate: async (vars): Promise<MutationContext> => {
70
+ const patch = vars.body.flags ?? {};
71
+ await queryClient.cancelQueries({ queryKey: addressCacheKey });
72
+
73
+ const previous =
74
+ queryClient.getQueryData<AddressOperationsSearchAddressesResponse>(
75
+ addressCacheKey,
76
+ );
77
+
78
+ queryClient.setQueryData<AddressOperationsSearchAddressesResponse>(
79
+ addressCacheKey,
80
+ (old) => {
81
+ if (!old) return old;
82
+ return {
83
+ ...old,
84
+ items: old.items.map((addr) =>
85
+ addr.addressId === vars.path.addressId
86
+ ? { ...addr, flags: applyFlagPatch(addr.flags, patch) }
87
+ : addr,
88
+ ),
89
+ };
90
+ },
91
+ );
92
+
93
+ return { previous };
94
+ },
95
+ onError: (err, _vars, context) => {
96
+ if (context?.previous !== undefined) {
97
+ queryClient.setQueryData(addressCacheKey, context.previous);
98
+ }
99
+ pushError({
100
+ title: "Couldn't update sender preference",
101
+ detail: formatErrorDetail(err),
102
+ });
103
+ },
104
+ onSettled: () => {
105
+ queryClient.invalidateQueries({ queryKey: addressCacheKey });
106
+ },
107
+ });
108
+
109
+ const updateFlags = useCallback(
110
+ (flags: RemitImapUpdateAddressFlagsInput) => {
111
+ if (!addressId) {
112
+ reportFatalError(
113
+ new Error(
114
+ `Quick action fired but the sender's address record did not resolve (sender: ${senderEmail ?? "unknown"}). This should never happen — the address row exists.`,
115
+ ),
116
+ );
117
+ return;
118
+ }
119
+ mutate({ path: { addressId }, body: { flags } });
120
+ },
121
+ [addressId, mutate, senderEmail],
122
+ );
123
+
124
+ return { updateFlags, isPending };
125
+ }
@@ -0,0 +1,127 @@
1
+ import assert from "node:assert";
2
+ import { beforeEach, describe, test } from "node:test";
3
+
4
+ /**
5
+ * Unit test for useVisualViewport hook logic.
6
+ * Tests the core behaviour without React — mirrors the pattern used
7
+ * by useLongPress.test.ts in this project.
8
+ */
9
+
10
+ const KEYBOARD_THRESHOLD = 150;
11
+
12
+ interface ViewportState {
13
+ viewportHeight: number;
14
+ isKeyboardOpen: boolean;
15
+ }
16
+
17
+ /**
18
+ * Simulates the viewport-tracking logic from useVisualViewport.
19
+ */
20
+ class VisualViewportSimulator {
21
+ private innerHeight: number;
22
+ private viewportHeight: number;
23
+ private listeners: Array<() => void> = [];
24
+
25
+ constructor(innerHeight: number) {
26
+ this.innerHeight = innerHeight;
27
+ this.viewportHeight = innerHeight;
28
+ }
29
+
30
+ getState(): ViewportState {
31
+ return {
32
+ viewportHeight: this.viewportHeight,
33
+ isKeyboardOpen:
34
+ this.innerHeight - this.viewportHeight > KEYBOARD_THRESHOLD,
35
+ };
36
+ }
37
+
38
+ /** Simulate a visualViewport resize (e.g. keyboard appearing). */
39
+ resize(newViewportHeight: number): ViewportState {
40
+ this.viewportHeight = newViewportHeight;
41
+ for (const fn of this.listeners) fn();
42
+ return this.getState();
43
+ }
44
+
45
+ addListener(fn: () => void): void {
46
+ this.listeners.push(fn);
47
+ }
48
+
49
+ removeListener(fn: () => void): void {
50
+ this.listeners = this.listeners.filter((l) => l !== fn);
51
+ }
52
+
53
+ get listenerCount(): number {
54
+ return this.listeners.length;
55
+ }
56
+ }
57
+
58
+ describe("useVisualViewport logic", () => {
59
+ let sim: VisualViewportSimulator;
60
+
61
+ beforeEach(() => {
62
+ // iPhone-like viewport: 844px tall
63
+ sim = new VisualViewportSimulator(844);
64
+ });
65
+
66
+ test("initial state has keyboard closed", () => {
67
+ const state = sim.getState();
68
+ assert.strictEqual(state.viewportHeight, 844);
69
+ assert.strictEqual(state.isKeyboardOpen, false);
70
+ });
71
+
72
+ test("keyboard open when viewport shrinks by more than threshold", () => {
73
+ // Typical iOS keyboard: viewport shrinks to ~400px
74
+ const state = sim.resize(400);
75
+ assert.strictEqual(state.isKeyboardOpen, true);
76
+ assert.strictEqual(state.viewportHeight, 400);
77
+ });
78
+
79
+ test("keyboard stays closed for small viewport changes", () => {
80
+ // Address bar collapsing — only ~50px change
81
+ const state = sim.resize(794);
82
+ assert.strictEqual(state.isKeyboardOpen, false);
83
+ });
84
+
85
+ test("keyboard closes when viewport restores", () => {
86
+ sim.resize(400);
87
+ const state = sim.resize(844);
88
+ assert.strictEqual(state.isKeyboardOpen, false);
89
+ assert.strictEqual(state.viewportHeight, 844);
90
+ });
91
+
92
+ test("exactly at threshold boundary — not open", () => {
93
+ // innerHeight - viewportHeight === 150, not > 150
94
+ const state = sim.resize(844 - KEYBOARD_THRESHOLD);
95
+ assert.strictEqual(state.isKeyboardOpen, false);
96
+ });
97
+
98
+ test("one pixel past threshold — keyboard open", () => {
99
+ const state = sim.resize(844 - KEYBOARD_THRESHOLD - 1);
100
+ assert.strictEqual(state.isKeyboardOpen, true);
101
+ });
102
+
103
+ test("listeners are called on resize", () => {
104
+ let callCount = 0;
105
+ sim.addListener(() => {
106
+ callCount++;
107
+ });
108
+ sim.resize(400);
109
+ sim.resize(844);
110
+ assert.strictEqual(callCount, 2);
111
+ });
112
+
113
+ test("removeListener stops notifications", () => {
114
+ let callCount = 0;
115
+ const fn = () => {
116
+ callCount++;
117
+ };
118
+ sim.addListener(fn);
119
+ sim.resize(400);
120
+ assert.strictEqual(callCount, 1);
121
+
122
+ sim.removeListener(fn);
123
+ sim.resize(844);
124
+ assert.strictEqual(callCount, 1);
125
+ assert.strictEqual(sim.listenerCount, 0);
126
+ });
127
+ });
@@ -0,0 +1,51 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ interface VisualViewportState {
4
+ /** Current visual viewport height in pixels. */
5
+ viewportHeight: number;
6
+ /** True when the viewport has shrunk significantly (e.g. software keyboard open). */
7
+ isKeyboardOpen: boolean;
8
+ }
9
+
10
+ /** Minimum shrinkage (px) from window.innerHeight to consider the keyboard open. */
11
+ const KEYBOARD_THRESHOLD = 150;
12
+
13
+ /**
14
+ * Tracks `window.visualViewport` resize events and reports whether the
15
+ * software keyboard is likely open. SSR-safe — returns safe defaults
16
+ * when `window` or `visualViewport` are unavailable.
17
+ */
18
+ export const useVisualViewport = (): VisualViewportState => {
19
+ const [state, setState] = useState<VisualViewportState>(() => {
20
+ if (typeof window === "undefined" || !window.visualViewport) {
21
+ return { viewportHeight: 0, isKeyboardOpen: false };
22
+ }
23
+ const h = window.visualViewport.height;
24
+ return {
25
+ viewportHeight: h,
26
+ isKeyboardOpen: window.innerHeight - h > KEYBOARD_THRESHOLD,
27
+ };
28
+ });
29
+
30
+ useEffect(() => {
31
+ if (typeof window === "undefined" || !window.visualViewport) return;
32
+
33
+ const vv = window.visualViewport;
34
+
35
+ const update = () => {
36
+ const h = vv.height;
37
+ setState({
38
+ viewportHeight: h,
39
+ isKeyboardOpen: window.innerHeight - h > KEYBOARD_THRESHOLD,
40
+ });
41
+ };
42
+
43
+ // Sync on mount
44
+ update();
45
+
46
+ vv.addEventListener("resize", update);
47
+ return () => vv.removeEventListener("resize", update);
48
+ }, []);
49
+
50
+ return state;
51
+ };
package/src/index.css ADDED
@@ -0,0 +1,19 @@
1
+ /* remit-ui owns @import "tailwindcss", the @theme token block, :root and
2
+ .dark — importing its token sheet gives the web client the full design
3
+ system. The @custom-variant dark declaration inside tokens.css covers
4
+ the .dark ancestor scope used everywhere in this app. */
5
+ @import "@remit/ui/tokens.css";
6
+
7
+ /* Tailwind v4 content scanning: point at the source Tailwind must inspect
8
+ for utility class usage (auto-detection misses linked workspace packages). */
9
+ @source "../../ui/src/**/*.{ts,tsx}";
10
+
11
+ /* Hide scrollbars while maintaining scroll functionality */
12
+ .overflow-y-auto {
13
+ scrollbar-width: none; /* Firefox */
14
+ -ms-overflow-style: none; /* IE/Edge */
15
+ }
16
+
17
+ .overflow-y-auto::-webkit-scrollbar {
18
+ display: none; /* Chrome, Safari, Opera */
19
+ }