@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,102 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { organizeOperationsCreateOrganizeJobMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
4
+ import { MutationObserver } from "@tanstack/query-core";
5
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
6
+ import React, { createElement } from "react";
7
+ import { renderToString } from "react-dom/server";
8
+ import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
9
+ import { buildOrganizeInput } from "@/lib/organize/organize-model";
10
+ import { OrganizePanel } from "./OrganizePanel";
11
+
12
+ // The node test loader transpiles remit-ui's `.tsx` with the classic JSX
13
+ // runtime, which references a global `React`. Vite uses the automatic runtime,
14
+ // so this shim only exists for the SSR test harness.
15
+ (globalThis as { React?: typeof React }).React = React;
16
+
17
+ const render = () =>
18
+ renderToString(
19
+ createElement(
20
+ QueryClientProvider,
21
+ { client: new QueryClient() },
22
+ createElement(
23
+ ErrorBannerProvider,
24
+ null,
25
+ createElement(OrganizePanel, {
26
+ accountId: "acc-1",
27
+ mailboxId: "mbx-inbox",
28
+ selectedMessageIds: ["msg-1", "msg-2"],
29
+ anchorMessageId: "msg-1",
30
+ matchedCount: 47,
31
+ onClose: () => undefined,
32
+ }),
33
+ ),
34
+ ) as never,
35
+ );
36
+
37
+ describe("OrganizePanel", () => {
38
+ it("renders the organize sentence with the widened count", () => {
39
+ const html = render();
40
+ assert.match(html, /similar message/);
41
+ assert.match(html, /from 2 selected/);
42
+ });
43
+
44
+ it("surfaces the disabled reason until a folder is chosen (ux.md — say why)", () => {
45
+ const html = render();
46
+ assert.match(html, /Pick a folder to move these into/);
47
+ assert.match(html, /available yet/i);
48
+ });
49
+
50
+ it("offers all four commit scopes", () => {
51
+ const html = render();
52
+ assert.match(html, /Just these/);
53
+ assert.match(html, /All like these/);
54
+ assert.match(html, /These and new mail like this/);
55
+ assert.match(html, /Until a date/);
56
+ });
57
+ });
58
+
59
+ // The commit button's `disabled` prop is
60
+ // `!!disabledReason || createFilter.isPending || organizeJob.isStarting`
61
+ // (#1279) — a slow POST to /organize must block a second click just like an
62
+ // in-flight createFilter does, or the back-apply runs twice. `renderToString`
63
+ // never dispatches the click that would exercise that wiring end-to-end (SSR
64
+ // doesn't run event handlers), so this drives the exact mutation config
65
+ // `useOrganizeJob().start()` calls — `organizeOperationsCreateOrganizeJobMutation()`
66
+ // — through a real `MutationObserver` and confirms it reports `isPending`
67
+ // (what `organizeJob.isStarting` is) for the whole time the POST is in
68
+ // flight, which is the invariant the disabled expression relies on.
69
+ describe("organizeJob.isStarting — the create-organize-job POST stays pending until it resolves (#1279)", () => {
70
+ it("reports isPending immediately after start() and keeps it pending while the POST hasn't come back", () => {
71
+ const queryClient = new QueryClient();
72
+ const observer = new MutationObserver(
73
+ queryClient,
74
+ organizeOperationsCreateOrganizeJobMutation(),
75
+ );
76
+
77
+ assert.equal(
78
+ observer.getCurrentResult().isPending,
79
+ false,
80
+ "idle before the first submit — same as a fresh commit button",
81
+ );
82
+
83
+ observer.mutate({
84
+ path: { accountId: "acc-1" },
85
+ body: buildOrganizeInput({
86
+ anchorMessageId: "msg-1",
87
+ matchOperator: "And",
88
+ literalClauses: [],
89
+ moveMailboxId: "mbx-work",
90
+ }),
91
+ // Stands in for a slow POST that hasn't come back yet — the exact
92
+ // window a double click must be blocked in.
93
+ fetch: () => new Promise<Response>(() => {}),
94
+ });
95
+
96
+ assert.equal(
97
+ observer.getCurrentResult().isPending,
98
+ true,
99
+ "a second click during this window must be blocked — this is what organizeJob.isStarting disables the button on",
100
+ );
101
+ });
102
+ });
@@ -0,0 +1,344 @@
1
+ import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import { Badge, Button, Input, Select } from "@remit/ui";
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import { CheckCircle2, Loader2 } from "lucide-react";
5
+ import { useMemo, useState } from "react";
6
+ import { useCreateFilter } from "@/hooks/useFilters";
7
+ import { useMoveMessages } from "@/hooks/useMoveMessages";
8
+ import { useOrganizeJob } from "@/hooks/useOrganizeJob";
9
+ import { getMailboxDisplayName } from "@/lib/folder-roles";
10
+ import { buildMoveTargets } from "@/lib/move-targets";
11
+ import { pickedDateToExpiresAt } from "@/lib/organize/filter-status";
12
+ import {
13
+ commitButtonLabel,
14
+ commitDisabledReason,
15
+ scopeActionCount,
16
+ } from "@/lib/organize/organize-copy";
17
+ import type {
18
+ OrganizeDraft,
19
+ OrganizeScope,
20
+ } from "@/lib/organize/organize-model";
21
+
22
+ interface OrganizePanelProps {
23
+ accountId: string;
24
+ mailboxId: string;
25
+ selectedMessageIds: string[];
26
+ anchorMessageId: string;
27
+ /** Similar messages the widen preview matched. */
28
+ matchedCount: number;
29
+ onClose: () => void;
30
+ }
31
+
32
+ const SCOPE_OPTIONS: { id: OrganizeScope; label: string; caption: string }[] = [
33
+ {
34
+ id: "just-these",
35
+ label: "Just these",
36
+ caption: "the ones you selected",
37
+ },
38
+ {
39
+ id: "all-like-these",
40
+ label: "All like these",
41
+ caption: "including the similar ones found now",
42
+ },
43
+ {
44
+ id: "standing",
45
+ label: "These and new mail like this",
46
+ caption: "keeps working on future mail",
47
+ },
48
+ {
49
+ id: "temporary",
50
+ label: "Until a date",
51
+ caption: "stops on its own",
52
+ },
53
+ ];
54
+
55
+ export function OrganizePanel({
56
+ accountId,
57
+ mailboxId,
58
+ selectedMessageIds,
59
+ anchorMessageId,
60
+ matchedCount,
61
+ onClose,
62
+ }: OrganizePanelProps) {
63
+ const [scope, setScope] = useState<OrganizeScope>("all-like-these");
64
+ const [moveMailboxId, setMoveMailboxId] = useState<string>("");
65
+ const [name, setName] = useState("");
66
+ const [pickedDate, setPickedDate] = useState("");
67
+
68
+ const { data: mailboxesData } = useQuery({
69
+ ...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
70
+ staleTime: Infinity,
71
+ });
72
+
73
+ const folderOptions = useMemo(() => {
74
+ const targets = buildMoveTargets(mailboxesData?.items ?? []);
75
+ return targets.map((mailbox) => ({
76
+ id: mailbox.mailboxId,
77
+ label: getMailboxDisplayName(mailbox.fullPath),
78
+ }));
79
+ }, [mailboxesData?.items]);
80
+
81
+ const draft: OrganizeDraft = useMemo(
82
+ () => ({
83
+ anchorMessageId,
84
+ matchOperator: "And",
85
+ literalClauses: [],
86
+ moveMailboxId: moveMailboxId || undefined,
87
+ expiresAt:
88
+ scope === "temporary" ? pickedDateToExpiresAt(pickedDate) : undefined,
89
+ }),
90
+ [anchorMessageId, moveMailboxId, scope, pickedDate],
91
+ );
92
+
93
+ const { moveMessages } = useMoveMessages({ mailboxId, accountId });
94
+ const organizeJob = useOrganizeJob(accountId);
95
+ const createFilter = useCreateFilter(accountId);
96
+
97
+ const selectionCount = selectedMessageIds.length;
98
+ const actionCount = scopeActionCount(scope, selectionCount, matchedCount);
99
+
100
+ const disabledReason = commitDisabledReason({
101
+ draft,
102
+ scope,
103
+ name,
104
+ pickedDate,
105
+ });
106
+
107
+ const handleCommit = () => {
108
+ if (disabledReason) return;
109
+ switch (scope) {
110
+ case "just-these":
111
+ if (moveMailboxId) moveMessages(selectedMessageIds, moveMailboxId);
112
+ onClose();
113
+ return;
114
+ case "all-like-these":
115
+ organizeJob.start(draft);
116
+ return;
117
+ case "standing":
118
+ createFilter.createFilter(draft, "standing", name.trim());
119
+ return;
120
+ case "temporary":
121
+ createFilter.createFilter(draft, "temporary", name.trim());
122
+ return;
123
+ }
124
+ };
125
+
126
+ if (organizeJob.isRunning || organizeJob.isDone) {
127
+ return (
128
+ <JobProgress
129
+ progress={organizeJob.progress}
130
+ isDone={organizeJob.isDone}
131
+ onClose={onClose}
132
+ />
133
+ );
134
+ }
135
+
136
+ if (createFilter.isSuccess) {
137
+ return (
138
+ <div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
139
+ <CheckCircle2 className="size-8 text-positive" />
140
+ <p className="text-sm font-medium text-fg">Filter saved</p>
141
+ <p className="max-w-xs text-xs text-fg-muted">
142
+ You can see it, and when it expires, under Settings › Filters.
143
+ </p>
144
+ <Button variant="primary" onClick={onClose} className="mt-2">
145
+ Done
146
+ </Button>
147
+ </div>
148
+ );
149
+ }
150
+
151
+ const needsName = scope === "standing" || scope === "temporary";
152
+
153
+ return (
154
+ <div className="flex min-h-0 flex-col">
155
+ <div className="border-b border-line px-5 py-3">
156
+ <h2 className="text-sm font-semibold text-fg">Organize</h2>
157
+ <p className="mt-0.5 text-xs text-fg-muted">
158
+ {matchedCount} similar message{matchedCount === 1 ? "" : "s"} found
159
+ {selectionCount > 0 ? ` from ${selectionCount} selected` : ""}.
160
+ </p>
161
+ </div>
162
+
163
+ <div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-4">
164
+ <section className="space-y-2 rounded-lg border border-line bg-surface-sunken p-3">
165
+ <p className="text-sm leading-relaxed text-fg-muted">
166
+ {scope === "standing" && (
167
+ <span className="font-semibold text-fg">Always keep </span>
168
+ )}
169
+ {scope === "standing" ? "" : "Keep "}
170
+ {scope === "just-these" ? "these" : "emails like these"} in
171
+ </p>
172
+ <Select
173
+ aria-label="Destination folder"
174
+ value={moveMailboxId}
175
+ onChange={(e) => setMoveMailboxId(e.target.value)}
176
+ >
177
+ <option value="">Choose a folder…</option>
178
+ {folderOptions.map((option) => (
179
+ <option key={option.id} value={option.id}>
180
+ {option.label}
181
+ </option>
182
+ ))}
183
+ </Select>
184
+
185
+ <div className="flex items-center gap-2 pt-1">
186
+ <Badge tone="neutral">label them…</Badge>
187
+ <span className="text-2xs text-fg-subtle">
188
+ Labeling isn't available yet — arrives with mail-labeling (RFC
189
+ 031).
190
+ </span>
191
+ </div>
192
+ </section>
193
+
194
+ <section className="space-y-1.5">
195
+ {SCOPE_OPTIONS.map((option) => {
196
+ const active = scope === option.id;
197
+ return (
198
+ <button
199
+ key={option.id}
200
+ type="button"
201
+ onClick={() => setScope(option.id)}
202
+ className={`flex w-full items-start gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors ${
203
+ active
204
+ ? "border-accent-2 bg-accent-2-soft"
205
+ : "border-line bg-surface hover:bg-surface-sunken"
206
+ }`}
207
+ >
208
+ <span
209
+ className={`mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border ${
210
+ active
211
+ ? "border-accent-2 bg-accent-2"
212
+ : "border-line bg-surface"
213
+ }`}
214
+ >
215
+ {active && (
216
+ <span className="size-1.5 rounded-full bg-white" />
217
+ )}
218
+ </span>
219
+ <span className="min-w-0">
220
+ <span
221
+ className={`block text-sm font-medium ${
222
+ active ? "text-accent-2" : "text-fg"
223
+ }`}
224
+ >
225
+ {option.label}
226
+ </span>
227
+ <span className="block text-xs text-fg-subtle">
228
+ {option.caption}
229
+ </span>
230
+ </span>
231
+ </button>
232
+ );
233
+ })}
234
+ </section>
235
+
236
+ {needsName && (
237
+ <section className="space-y-2">
238
+ <Input
239
+ value={name}
240
+ onChange={(e) => setName(e.target.value)}
241
+ placeholder="Name this filter (e.g. Travel)"
242
+ aria-label="Filter name"
243
+ />
244
+ {scope === "temporary" && (
245
+ <label className="flex items-center gap-2 text-xs text-fg-muted">
246
+ Until
247
+ <input
248
+ type="date"
249
+ value={pickedDate}
250
+ onChange={(e) => setPickedDate(e.target.value)}
251
+ aria-label="Expiry date"
252
+ className="rounded-md border border-line bg-surface-sunken px-2 py-1 text-sm text-fg"
253
+ />
254
+ </label>
255
+ )}
256
+ </section>
257
+ )}
258
+ </div>
259
+
260
+ <div className="space-y-2 border-t border-line px-5 py-3">
261
+ {disabledReason && (
262
+ // biome-ignore lint/a11y/useSemanticElements: <p> with role="status" keeps block layout; <output> is inline
263
+ <p className="text-xs text-fg-subtle" role="status">
264
+ {disabledReason}
265
+ </p>
266
+ )}
267
+ {createFilter.isError && (
268
+ <p className="text-xs text-danger" role="alert">
269
+ Couldn't save the filter. Please try again.
270
+ </p>
271
+ )}
272
+ <Button
273
+ variant="primary"
274
+ onClick={handleCommit}
275
+ disabled={
276
+ !!disabledReason || createFilter.isPending || organizeJob.isStarting
277
+ }
278
+ className="w-full"
279
+ >
280
+ {createFilter.isPending
281
+ ? "Saving…"
282
+ : commitButtonLabel(scope, actionCount)}
283
+ </Button>
284
+ <Button variant="ghost" onClick={onClose} className="w-full">
285
+ Not now
286
+ </Button>
287
+ </div>
288
+ </div>
289
+ );
290
+ }
291
+
292
+ function JobProgress({
293
+ progress,
294
+ isDone,
295
+ onClose,
296
+ }: {
297
+ progress: ReturnType<typeof useOrganizeJob>["progress"];
298
+ isDone: boolean;
299
+ onClose: () => void;
300
+ }) {
301
+ const failed = progress.state === "Failed";
302
+ return (
303
+ <div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
304
+ {!isDone ? (
305
+ <Loader2 className="size-8 animate-spin text-accent-2" />
306
+ ) : failed ? (
307
+ <span className="text-sm font-semibold text-danger">
308
+ Organize failed
309
+ </span>
310
+ ) : (
311
+ <CheckCircle2 className="size-8 text-positive" />
312
+ )}
313
+
314
+ {!isDone && (
315
+ <p className="text-sm font-medium text-fg">Organizing similar mail…</p>
316
+ )}
317
+
318
+ {isDone && !failed && (
319
+ <div className="text-sm text-fg">
320
+ <p className="font-medium">Done</p>
321
+ <p className="mt-1 text-xs text-fg-muted">
322
+ {progress.appliedCount} of {progress.matchedCount} moved
323
+ {progress.failedCount > 0
324
+ ? ` · ${progress.failedCount} failed`
325
+ : ""}
326
+ .
327
+ </p>
328
+ </div>
329
+ )}
330
+
331
+ {isDone && failed && (
332
+ <p className="max-w-xs text-xs text-fg-muted">
333
+ {progress.errorMessage || "Something went wrong. Please try again."}
334
+ </p>
335
+ )}
336
+
337
+ {isDone && (
338
+ <Button variant="primary" onClick={onClose} className="mt-2">
339
+ Done
340
+ </Button>
341
+ )}
342
+ </div>
343
+ );
344
+ }