@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,63 @@
1
+ import { createContext, type FC, type ReactNode, useContext } from "react";
2
+
3
+ /**
4
+ * The auth-integration seam. A deployment composes the web client against
5
+ * exactly one provider — the identity system it runs — and never ships the
6
+ * others. `mountApp` receives the chosen provider and publishes it here; the
7
+ * app shell and every screen read it through `useAuthProvider`, so no surface
8
+ * imports a specific identity system directly.
9
+ */
10
+ export interface AuthAccount {
11
+ email: string | null;
12
+ signOut: () => void | Promise<void>;
13
+ }
14
+
15
+ export interface AuthAccountProps {
16
+ children: (account: AuthAccount) => ReactNode;
17
+ /** Rendered when no session is active. Defaults to nothing. */
18
+ fallback?: ReactNode;
19
+ }
20
+
21
+ export interface AuthProvider {
22
+ /** Called once at boot, before render, to initialize the identity SDK. */
23
+ configure(): void;
24
+ /**
25
+ * The current session's bearer token, or `null` when signed out. The API
26
+ * interceptor and the raw content fetch read it through here, so the identity
27
+ * SDK stays inside the provider.
28
+ */
29
+ getToken(): Promise<string | null>;
30
+ /** Drop any cached token so the next `getToken` re-mints. */
31
+ resetToken(): void;
32
+ /** The sign-in gate: renders children only once a session is established. */
33
+ Shell: FC<{ children: ReactNode }>;
34
+ /**
35
+ * Render-prop over the active session's identity and sign-out action.
36
+ * Renders `fallback` when no session is active.
37
+ */
38
+ Account: FC<AuthAccountProps>;
39
+ }
40
+
41
+ const Passthrough: FC<{ children?: ReactNode }> = ({ children }) => (
42
+ <>{children}</>
43
+ );
44
+
45
+ /**
46
+ * The zero-identity provider. It has no session and no identity SDK, so a
47
+ * surface that renders without a mounted provider (an isolated component
48
+ * render, a harness) degrades to "signed out" instead of crashing.
49
+ */
50
+ export const noneAuthProvider: AuthProvider = {
51
+ configure: () => {},
52
+ getToken: async () => null,
53
+ resetToken: () => {},
54
+ Shell: Passthrough,
55
+ Account: ({ fallback = null }) => <>{fallback}</>,
56
+ };
57
+
58
+ const AuthProviderContext = createContext<AuthProvider>(noneAuthProvider);
59
+
60
+ export const AuthProviderProvider = AuthProviderContext.Provider;
61
+
62
+ export const useAuthProvider = (): AuthProvider =>
63
+ useContext(AuthProviderContext);
@@ -0,0 +1,40 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import { isSignOutVisible } from "./sign-out-visibility";
4
+
5
+ describe("isSignOutVisible", () => {
6
+ test("returns true when configured and authenticated", () => {
7
+ assert.equal(
8
+ isSignOutVisible({ configured: true, authStatus: "authenticated" }),
9
+ true,
10
+ );
11
+ });
12
+
13
+ test("returns false when cognito is not configured", () => {
14
+ assert.equal(
15
+ isSignOutVisible({ configured: false, authStatus: "authenticated" }),
16
+ false,
17
+ );
18
+ });
19
+
20
+ test("returns false when unauthenticated", () => {
21
+ assert.equal(
22
+ isSignOutVisible({ configured: true, authStatus: "unauthenticated" }),
23
+ false,
24
+ );
25
+ });
26
+
27
+ test("returns false while auth status is configuring", () => {
28
+ assert.equal(
29
+ isSignOutVisible({ configured: true, authStatus: "configuring" }),
30
+ false,
31
+ );
32
+ });
33
+
34
+ test("returns false when auth status is undefined", () => {
35
+ assert.equal(
36
+ isSignOutVisible({ configured: true, authStatus: undefined }),
37
+ false,
38
+ );
39
+ });
40
+ });
@@ -0,0 +1,10 @@
1
+ export interface SignOutMenuVisibilityInput {
2
+ configured: boolean;
3
+ authStatus: string | undefined;
4
+ }
5
+
6
+ export const isSignOutVisible = ({
7
+ configured,
8
+ authStatus,
9
+ }: SignOutMenuVisibilityInput): boolean =>
10
+ configured && authStatus === "authenticated";
@@ -0,0 +1,23 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ isSignUpDisabledError,
5
+ SIGN_UP_DISABLED_CODE,
6
+ } from "./better-auth-config";
7
+
8
+ describe("isSignUpDisabledError", () => {
9
+ it("recognises better-auth's closed-signup rejection by code", () => {
10
+ assert.equal(
11
+ isSignUpDisabledError({ code: SIGN_UP_DISABLED_CODE, status: 400 }),
12
+ true,
13
+ );
14
+ });
15
+
16
+ it("ignores any other error or non-object", () => {
17
+ assert.equal(isSignUpDisabledError({ code: "INVALID_EMAIL" }), false);
18
+ assert.equal(isSignUpDisabledError(new Error("network down")), false);
19
+ assert.equal(isSignUpDisabledError(null), false);
20
+ assert.equal(isSignUpDisabledError(undefined), false);
21
+ assert.equal(isSignUpDisabledError("nope"), false);
22
+ });
23
+ });
@@ -0,0 +1,237 @@
1
+ import { addressOperationsSearchAddressesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type { RemitImapAddressResponse } from "@remit/api-http-client/types.gen.ts";
3
+ import { AddressTag } from "@remit/ui";
4
+ import { useQuery } from "@tanstack/react-query";
5
+ import { useCallback, useEffect, useRef, useState } from "react";
6
+ import { useDebouncedValue } from "@/hooks/useDebouncedValue";
7
+ import { cn } from "@/lib/utils";
8
+
9
+ export interface AddressEntry {
10
+ email: string;
11
+ displayName?: string;
12
+ }
13
+
14
+ interface AddressFieldProps {
15
+ label: string;
16
+ addresses: AddressEntry[];
17
+ onChange: (addresses: AddressEntry[]) => void;
18
+ placeholder?: string;
19
+ }
20
+
21
+ const formatSuggestion = (addr: RemitImapAddressResponse): string => {
22
+ if (addr.displayName) {
23
+ return `${addr.displayName} <${addr.normalizedEmail}>`;
24
+ }
25
+ return addr.normalizedEmail;
26
+ };
27
+
28
+ const isValidEmail = (value: string): boolean =>
29
+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
30
+
31
+ const parseEmailInput = (value: string): AddressEntry | undefined => {
32
+ const trimmed = value.trim();
33
+ if (!trimmed) return undefined;
34
+
35
+ const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/);
36
+ if (angleMatch) {
37
+ const displayName = angleMatch[1].trim();
38
+ const email = angleMatch[2].trim();
39
+ if (isValidEmail(email)) return { email, displayName };
40
+ }
41
+
42
+ if (isValidEmail(trimmed)) return { email: trimmed };
43
+ return undefined;
44
+ };
45
+
46
+ export const AddressField = ({
47
+ label,
48
+ addresses,
49
+ onChange,
50
+ placeholder,
51
+ }: AddressFieldProps) => {
52
+ const [inputValue, setInputValue] = useState("");
53
+ const [highlightedIndex, setHighlightedIndex] = useState(-1);
54
+ const [isOpen, setIsOpen] = useState(false);
55
+ const inputRef = useRef<HTMLInputElement>(null);
56
+ const listRef = useRef<HTMLUListElement>(null);
57
+
58
+ const debouncedQuery = useDebouncedValue(inputValue, 200);
59
+
60
+ const { data: suggestionsData } = useQuery({
61
+ ...addressOperationsSearchAddressesOptions({
62
+ query: { q: debouncedQuery, limit: 8 },
63
+ }),
64
+ enabled: debouncedQuery.length >= 2,
65
+ });
66
+
67
+ const suggestions = suggestionsData?.items ?? [];
68
+ const existingEmails = new Set(addresses.map((a) => a.email.toLowerCase()));
69
+ const filteredSuggestions = suggestions.filter(
70
+ (s) => !existingEmails.has(s.normalizedEmail.toLowerCase()),
71
+ );
72
+
73
+ useEffect(() => {
74
+ setHighlightedIndex(-1);
75
+ setIsOpen(filteredSuggestions.length > 0 && inputValue.length >= 2);
76
+ }, [filteredSuggestions.length, inputValue.length]);
77
+
78
+ const addAddress = useCallback(
79
+ (entry: AddressEntry) => {
80
+ if (existingEmails.has(entry.email.toLowerCase())) return;
81
+ onChange([...addresses, entry]);
82
+ setInputValue("");
83
+ setIsOpen(false);
84
+ },
85
+ [addresses, existingEmails, onChange],
86
+ );
87
+
88
+ const removeAddress = useCallback(
89
+ (index: number) => {
90
+ onChange(addresses.filter((_, i) => i !== index));
91
+ },
92
+ [addresses, onChange],
93
+ );
94
+
95
+ const selectSuggestion = useCallback(
96
+ (suggestion: RemitImapAddressResponse) => {
97
+ addAddress({
98
+ email: suggestion.normalizedEmail,
99
+ displayName: suggestion.displayName,
100
+ });
101
+ inputRef.current?.focus();
102
+ },
103
+ [addAddress],
104
+ );
105
+
106
+ const commitInput = useCallback(() => {
107
+ const entry = parseEmailInput(inputValue);
108
+ if (entry) {
109
+ addAddress(entry);
110
+ }
111
+ }, [inputValue, addAddress]);
112
+
113
+ const handleKeyDown = useCallback(
114
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
115
+ if (e.key === "Backspace" && inputValue === "" && addresses.length > 0) {
116
+ removeAddress(addresses.length - 1);
117
+ return;
118
+ }
119
+
120
+ if (e.key === "Enter" || e.key === "Tab" || e.key === ",") {
121
+ if (highlightedIndex >= 0 && filteredSuggestions[highlightedIndex]) {
122
+ e.preventDefault();
123
+ selectSuggestion(filteredSuggestions[highlightedIndex]);
124
+ return;
125
+ }
126
+ if (inputValue.trim()) {
127
+ e.preventDefault();
128
+ commitInput();
129
+ }
130
+ return;
131
+ }
132
+
133
+ if (e.key === "ArrowDown" && isOpen) {
134
+ e.preventDefault();
135
+ setHighlightedIndex((prev) =>
136
+ prev < filteredSuggestions.length - 1 ? prev + 1 : 0,
137
+ );
138
+ return;
139
+ }
140
+
141
+ if (e.key === "ArrowUp" && isOpen) {
142
+ e.preventDefault();
143
+ setHighlightedIndex((prev) =>
144
+ prev > 0 ? prev - 1 : filteredSuggestions.length - 1,
145
+ );
146
+ return;
147
+ }
148
+
149
+ if (e.key === "Escape") {
150
+ setIsOpen(false);
151
+ setHighlightedIndex(-1);
152
+ }
153
+ },
154
+ [
155
+ inputValue,
156
+ addresses.length,
157
+ highlightedIndex,
158
+ filteredSuggestions,
159
+ isOpen,
160
+ removeAddress,
161
+ selectSuggestion,
162
+ commitInput,
163
+ ],
164
+ );
165
+
166
+ const handleBlur = useCallback(() => {
167
+ setTimeout(() => {
168
+ commitInput();
169
+ setIsOpen(false);
170
+ }, 150);
171
+ }, [commitInput]);
172
+
173
+ return (
174
+ <div className="relative">
175
+ <div className="flex items-start gap-2">
176
+ <label
177
+ htmlFor={`address-field-${label}`}
178
+ className="text-sm text-fg-muted shrink-0 w-12 pt-1.5"
179
+ >
180
+ {label}:
181
+ </label>
182
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: click-to-focus wrapper for the address input; keyboard is forwarded to the inner input */}
183
+ <div
184
+ className="flex-1 flex flex-wrap items-center gap-1 min-h-[36px] px-2 py-1 border rounded-md bg-canvas cursor-text"
185
+ onClick={() => inputRef.current?.focus()}
186
+ onKeyDown={(e) => {
187
+ if (e.key === "Enter" || e.key === " ") inputRef.current?.focus();
188
+ }}
189
+ >
190
+ {addresses.map((addr, i) => (
191
+ <AddressTag
192
+ key={addr.email}
193
+ email={addr.email}
194
+ displayName={addr.displayName}
195
+ onRemove={() => removeAddress(i)}
196
+ />
197
+ ))}
198
+ <input
199
+ ref={inputRef}
200
+ id={`address-field-${label}`}
201
+ type="text"
202
+ value={inputValue}
203
+ onChange={(e) => setInputValue(e.target.value)}
204
+ onKeyDown={handleKeyDown}
205
+ onBlur={handleBlur}
206
+ placeholder={addresses.length === 0 ? placeholder : ""}
207
+ className="flex-1 min-w-[120px] bg-transparent outline-none text-sm py-0.5"
208
+ />
209
+ </div>
210
+ </div>
211
+
212
+ {isOpen && filteredSuggestions.length > 0 && (
213
+ <ul
214
+ ref={listRef}
215
+ className="absolute left-12 right-0 mt-1 bg-surface border border-line rounded-md shadow-lg z-50 max-h-[200px] overflow-auto"
216
+ >
217
+ {filteredSuggestions.map((suggestion, index) => (
218
+ <li
219
+ key={suggestion.addressId}
220
+ className={cn(
221
+ "px-3 py-2 text-sm cursor-pointer transition-colors",
222
+ index === highlightedIndex && "bg-accent-2-soft",
223
+ )}
224
+ onMouseDown={(e) => {
225
+ e.preventDefault();
226
+ selectSuggestion(suggestion);
227
+ }}
228
+ onMouseEnter={() => setHighlightedIndex(index)}
229
+ >
230
+ {formatSuggestion(suggestion)}
231
+ </li>
232
+ ))}
233
+ </ul>
234
+ )}
235
+ </div>
236
+ );
237
+ };
@@ -0,0 +1,36 @@
1
+ import type { Value } from "platejs";
2
+ import { Plate } from "platejs/react";
3
+ import { PlateEditorContent, usePlateComposeEditor } from "./PlateEditor.js";
4
+ import { PlateToolbar } from "./PlateToolbar.js";
5
+
6
+ interface ComposeBodyProps {
7
+ value: Value;
8
+ onChange: (value: Value) => void;
9
+ onSubmit?: () => void;
10
+ autoFocus?: boolean;
11
+ }
12
+
13
+ export const ComposeBody = ({
14
+ value,
15
+ onChange,
16
+ onSubmit,
17
+ autoFocus,
18
+ }: ComposeBodyProps) => {
19
+ const editor = usePlateComposeEditor(value);
20
+
21
+ return (
22
+ <Plate
23
+ editor={editor}
24
+ onValueChange={({ value: v }) => {
25
+ onChange(v);
26
+ }}
27
+ >
28
+ <PlateToolbar />
29
+ <PlateEditorContent
30
+ editor={editor}
31
+ onSubmit={onSubmit}
32
+ autoFocus={autoFocus}
33
+ />
34
+ </Plate>
35
+ );
36
+ };