@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,133 @@
1
+ import {
2
+ outboxDetailOperationsGetOutboxMessageOptions,
3
+ outboxOperationsListOutboxMessagesQueryKey,
4
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
+ import type {
6
+ RemitImapAccountResponse,
7
+ RemitImapDescribeMessageResponse,
8
+ } from "@remit/api-http-client/types.gen.ts";
9
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
10
+ import {
11
+ createContext,
12
+ useCallback,
13
+ useContext,
14
+ useEffect,
15
+ useMemo,
16
+ useRef,
17
+ useState,
18
+ } from "react";
19
+ export type ComposeMode = "reply" | "reply_all" | "forward" | "new";
20
+
21
+ export interface ComposeState {
22
+ isOpen: boolean;
23
+ mode: ComposeMode;
24
+ account?: RemitImapAccountResponse;
25
+ sourceMessage?: RemitImapDescribeMessageResponse;
26
+ threadId?: string;
27
+ mailboxId?: string;
28
+ outboxMessageId?: string;
29
+ }
30
+
31
+ interface ComposeContextValue {
32
+ state: ComposeState;
33
+ openCompose: (params: Omit<ComposeState, "isOpen">) => void;
34
+ closeCompose: () => void;
35
+ setOutboxMessageId: (id: string) => void;
36
+ startSendPolling: (outboxMessageId: string) => void;
37
+ }
38
+
39
+ const ComposeContext = createContext<ComposeContextValue | undefined>(
40
+ undefined,
41
+ );
42
+
43
+ const INITIAL_STATE: ComposeState = {
44
+ isOpen: false,
45
+ mode: "new",
46
+ };
47
+
48
+ const POLL_INTERVAL_MS = 2000;
49
+ const MAX_POLL_DURATION_MS = 60_000;
50
+
51
+ export const ComposeProvider = ({
52
+ children,
53
+ }: {
54
+ children: React.ReactNode;
55
+ }) => {
56
+ const [state, setState] = useState<ComposeState>(INITIAL_STATE);
57
+ const [pollingMessageId, setPollingMessageId] = useState<
58
+ string | undefined
59
+ >();
60
+ const startedAtRef = useRef(0);
61
+ const queryClient = useQueryClient();
62
+
63
+ const { data: polledMessage } = useQuery({
64
+ ...outboxDetailOperationsGetOutboxMessageOptions({
65
+ path: { outboxMessageId: pollingMessageId ?? "" },
66
+ }),
67
+ enabled: !!pollingMessageId,
68
+ refetchInterval: (query) => {
69
+ const status = query.state.data?.status;
70
+ if (status === "sent" || status === "failed" || status === "blocked")
71
+ return false;
72
+ if (Date.now() - startedAtRef.current > MAX_POLL_DURATION_MS)
73
+ return false;
74
+ return POLL_INTERVAL_MS;
75
+ },
76
+ });
77
+
78
+ useEffect(() => {
79
+ if (!polledMessage || !pollingMessageId) return;
80
+
81
+ const terminal =
82
+ polledMessage.status === "sent" ||
83
+ polledMessage.status === "failed" ||
84
+ polledMessage.status === "blocked";
85
+
86
+ if (terminal) {
87
+ setPollingMessageId(undefined);
88
+ queryClient.invalidateQueries({
89
+ queryKey: outboxOperationsListOutboxMessagesQueryKey(),
90
+ });
91
+ }
92
+ }, [polledMessage, pollingMessageId, queryClient]);
93
+
94
+ const openCompose = useCallback((params: Omit<ComposeState, "isOpen">) => {
95
+ setState({ ...params, isOpen: true });
96
+ }, []);
97
+
98
+ const closeCompose = useCallback(() => {
99
+ setState(INITIAL_STATE);
100
+ }, []);
101
+
102
+ const setOutboxMessageId = useCallback((id: string) => {
103
+ setState((prev) => ({ ...prev, outboxMessageId: id }));
104
+ }, []);
105
+
106
+ const startSendPolling = useCallback((outboxMessageId: string) => {
107
+ startedAtRef.current = Date.now();
108
+ setPollingMessageId(outboxMessageId);
109
+ }, []);
110
+
111
+ const value = useMemo(
112
+ () => ({
113
+ state,
114
+ openCompose,
115
+ closeCompose,
116
+ setOutboxMessageId,
117
+ startSendPolling,
118
+ }),
119
+ [state, openCompose, closeCompose, setOutboxMessageId, startSendPolling],
120
+ );
121
+
122
+ return (
123
+ <ComposeContext.Provider value={value}>{children}</ComposeContext.Provider>
124
+ );
125
+ };
126
+
127
+ export const useCompose = (): ComposeContextValue => {
128
+ const context = useContext(ComposeContext);
129
+ if (!context) {
130
+ throw new Error("useCompose must be used within a ComposeProvider");
131
+ }
132
+ return context;
133
+ };
@@ -0,0 +1,49 @@
1
+ import { useNavigate } from "@tanstack/react-router";
2
+ import { AlertTriangle, ArrowRight } from "lucide-react";
3
+
4
+ interface ComposeSmtpMissingBannerProps {
5
+ accountId: string;
6
+ }
7
+
8
+ /**
9
+ * Non-dismissible banner shown above the compose form when the selected
10
+ * account has no SMTP host configured. Pairs with disabling the Send
11
+ * button so the user has a single, factual explanation of why sending
12
+ * is blocked. See issue #196.
13
+ */
14
+ export const ComposeSmtpMissingBanner = ({
15
+ accountId,
16
+ }: ComposeSmtpMissingBannerProps) => {
17
+ const navigate = useNavigate();
18
+
19
+ return (
20
+ <div
21
+ role="alert"
22
+ data-testid="compose-smtp-missing-banner"
23
+ className="flex items-start gap-3 border-b border-warning/50 bg-warning/10 px-3 py-2"
24
+ >
25
+ <AlertTriangle
26
+ className="size-5 shrink-0 mt-0.5 text-warning"
27
+ aria-hidden="true"
28
+ />
29
+ <div className="flex-1 min-w-0">
30
+ <p className="text-sm font-medium text-warning">
31
+ This account can't send mail until SMTP is configured.
32
+ </p>
33
+ <button
34
+ type="button"
35
+ onClick={() => {
36
+ navigate({
37
+ to: "/settings/accounts",
38
+ search: { editAccountId: accountId, focusSmtp: true },
39
+ });
40
+ }}
41
+ className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-warning hover:underline"
42
+ >
43
+ Configure SMTP
44
+ <ArrowRight className="size-3" aria-hidden="true" />
45
+ </button>
46
+ </div>
47
+ </div>
48
+ );
49
+ };
@@ -0,0 +1,67 @@
1
+ import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import { useEffect } from "react";
5
+
6
+ interface FromSelectorProps {
7
+ selectedAccountId?: string;
8
+ onSelect: (account: RemitImapAccountResponse) => void;
9
+ }
10
+
11
+ export const FromSelector = ({
12
+ selectedAccountId,
13
+ onSelect,
14
+ }: FromSelectorProps) => {
15
+ const { data: config } = useQuery({
16
+ ...configOperationsGetConfigOptions(),
17
+ staleTime: Infinity,
18
+ });
19
+ const accounts = config?.accounts ?? [];
20
+
21
+ // Auto-select the only account when none is selected
22
+ useEffect(() => {
23
+ if (!selectedAccountId && accounts.length === 1 && accounts[0]) {
24
+ onSelect(accounts[0]);
25
+ }
26
+ }, [selectedAccountId, accounts, onSelect]);
27
+
28
+ if (accounts.length <= 1) {
29
+ const account = accounts[0];
30
+ if (!account) return null;
31
+ return (
32
+ <div className="flex items-start gap-2">
33
+ {/* biome-ignore lint/a11y/noLabelWithoutControl: decorative label for a read-only value, not a form control */}
34
+ <label className="text-sm text-fg-muted shrink-0 w-12 pt-1.5">
35
+ From:
36
+ </label>
37
+ <div className="text-sm py-1.5">{account.email}</div>
38
+ </div>
39
+ );
40
+ }
41
+
42
+ return (
43
+ <div className="flex items-start gap-2">
44
+ <label
45
+ htmlFor="from-account-selector"
46
+ className="text-sm text-fg-muted shrink-0 w-12 pt-1.5"
47
+ >
48
+ From:
49
+ </label>
50
+ <select
51
+ id="from-account-selector"
52
+ value={selectedAccountId ?? ""}
53
+ onChange={(e) => {
54
+ const account = accounts.find((a) => a.accountId === e.target.value);
55
+ if (account) onSelect(account);
56
+ }}
57
+ className="flex-1 px-2 py-1.5 border rounded-md bg-canvas text-sm"
58
+ >
59
+ {accounts.map((account) => (
60
+ <option key={account.accountId} value={account.accountId}>
61
+ {account.email}
62
+ </option>
63
+ ))}
64
+ </select>
65
+ </div>
66
+ );
67
+ };
@@ -0,0 +1,82 @@
1
+ import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { X } from "lucide-react";
4
+ import { ErrorState } from "@/components/ui/ErrorState";
5
+ import { useIsDesktop } from "@/hooks/useMediaQuery";
6
+ import { ComposeForm } from "./ComposeForm";
7
+ import { useCompose } from "./ComposeProvider";
8
+ import { MobileComposeSheet } from "./MobileComposeSheet";
9
+
10
+ const MODE_LABELS: Record<string, string> = {
11
+ reply: "Reply",
12
+ reply_all: "Reply All",
13
+ forward: "Forward",
14
+ new: "New Message",
15
+ };
16
+
17
+ export const FullCompose = () => {
18
+ const { state, closeCompose } = useCompose();
19
+ const isDesktop = useIsDesktop();
20
+ const {
21
+ isError: isConfigError,
22
+ error: configError,
23
+ refetch: refetchConfig,
24
+ } = useQuery({
25
+ ...configOperationsGetConfigOptions(),
26
+ staleTime: Infinity,
27
+ });
28
+
29
+ if (!isDesktop) return <MobileComposeSheet />;
30
+
31
+ if (!state.isOpen) return null;
32
+
33
+ const title = MODE_LABELS[state.mode] ?? "New Message";
34
+
35
+ const header = (
36
+ <header className="flex items-center justify-between gap-2 px-4 py-3 border-b border-line shrink-0">
37
+ <div className="flex items-center gap-2 min-w-0">
38
+ <h2 className="text-lg font-semibold truncate">{title}</h2>
39
+ </div>
40
+ <button
41
+ type="button"
42
+ onClick={closeCompose}
43
+ className="p-2 rounded-md hover:bg-surface-raised transition-colors min-h-11 min-w-11 inline-flex items-center justify-center"
44
+ aria-label="Close"
45
+ >
46
+ <X className="size-5" />
47
+ </button>
48
+ </header>
49
+ );
50
+
51
+ if (isConfigError) {
52
+ return (
53
+ <div className="h-full flex flex-col bg-canvas">
54
+ {header}
55
+ <div className="flex-1 flex items-center justify-center p-4">
56
+ <ErrorState
57
+ title="Couldn't load compose"
58
+ error={configError}
59
+ onRetry={() => {
60
+ refetchConfig();
61
+ }}
62
+ />
63
+ </div>
64
+ </div>
65
+ );
66
+ }
67
+
68
+ return (
69
+ <div className="flex h-full min-h-0 flex-col bg-canvas">
70
+ {header}
71
+ <div className="min-h-0 flex-1 overflow-hidden">
72
+ <ComposeForm
73
+ key={state.outboxMessageId ?? "new"}
74
+ mode={state.mode}
75
+ account={state.account}
76
+ sourceMessage={state.sourceMessage}
77
+ onClose={closeCompose}
78
+ />
79
+ </div>
80
+ </div>
81
+ );
82
+ };
@@ -0,0 +1,29 @@
1
+ import type {
2
+ RemitImapAccountResponse,
3
+ RemitImapDescribeMessageResponse,
4
+ } from "@remit/api-http-client/types.gen.ts";
5
+ import { ComposeForm } from "./ComposeForm";
6
+ import type { ComposeMode } from "./ComposeProvider";
7
+
8
+ interface InlineComposeProps {
9
+ mode: ComposeMode;
10
+ account?: RemitImapAccountResponse;
11
+ sourceMessage?: RemitImapDescribeMessageResponse;
12
+ onClose: () => void;
13
+ }
14
+
15
+ export const InlineCompose = ({
16
+ mode,
17
+ account,
18
+ sourceMessage,
19
+ onClose,
20
+ }: InlineComposeProps) => (
21
+ <div className="border-t border-line bg-canvas max-h-[400px] flex flex-col">
22
+ <ComposeForm
23
+ mode={mode}
24
+ account={account}
25
+ sourceMessage={sourceMessage}
26
+ onClose={onClose}
27
+ />
28
+ </div>
29
+ );
@@ -0,0 +1,152 @@
1
+ import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { useCallback, useRef, useState } from "react";
4
+ import { Drawer } from "vaul";
5
+ import { ErrorState } from "@/components/ui/ErrorState";
6
+ import { ComposeForm } from "./ComposeForm";
7
+ import { useCompose } from "./ComposeProvider";
8
+
9
+ const MODE_LABELS: Record<string, string> = {
10
+ reply: "Reply",
11
+ reply_all: "Reply All",
12
+ forward: "Forward",
13
+ new: "New Message",
14
+ };
15
+
16
+ /**
17
+ * Returns true when the draft has meaningful content (subject or body
18
+ * typed by the user). Used to gate drag-to-dismiss with a confirmation.
19
+ */
20
+ const useIsDraftDirty = (): (() => boolean) => {
21
+ const dirtyRef = useRef(false);
22
+
23
+ // We peek at the DOM: if subject has text or the editor has non-empty
24
+ // text content, the draft is dirty. This avoids threading form state
25
+ // back up — ComposeForm owns the source of truth.
26
+ const check = useCallback((): boolean => {
27
+ const subject =
28
+ document.querySelector<HTMLInputElement>("[data-subject-field]")?.value ??
29
+ "";
30
+ const editorEl = document.querySelector("[data-slate-editor]");
31
+ const bodyText = editorEl?.textContent ?? "";
32
+ // Strip signature separator "-- " to avoid false positives
33
+ const cleaned = bodyText.replace(/--\s*/g, "").trim();
34
+ dirtyRef.current = subject.trim().length > 0 || cleaned.length > 0;
35
+ return dirtyRef.current;
36
+ }, []);
37
+
38
+ return check;
39
+ };
40
+
41
+ export const MobileComposeSheet = () => {
42
+ const { state, closeCompose } = useCompose();
43
+ const isDraftDirty = useIsDraftDirty();
44
+ const [showConfirm, setShowConfirm] = useState(false);
45
+
46
+ const {
47
+ isError: isConfigError,
48
+ error: configError,
49
+ refetch: refetchConfig,
50
+ } = useQuery({
51
+ ...configOperationsGetConfigOptions(),
52
+ staleTime: Infinity,
53
+ });
54
+
55
+ const handleOpenChange = useCallback(
56
+ (open: boolean) => {
57
+ if (open) return;
58
+ // User is trying to dismiss
59
+ if (isDraftDirty()) {
60
+ setShowConfirm(true);
61
+ } else {
62
+ closeCompose();
63
+ }
64
+ },
65
+ [closeCompose, isDraftDirty],
66
+ );
67
+
68
+ const handleConfirmDiscard = useCallback(() => {
69
+ setShowConfirm(false);
70
+ closeCompose();
71
+ }, [closeCompose]);
72
+
73
+ const handleCancelDiscard = useCallback(() => {
74
+ setShowConfirm(false);
75
+ }, []);
76
+
77
+ if (!state.isOpen) return null;
78
+
79
+ const title = MODE_LABELS[state.mode] ?? "New Message";
80
+
81
+ return (
82
+ <Drawer.Root
83
+ open={state.isOpen}
84
+ onOpenChange={handleOpenChange}
85
+ // Don't auto-dismiss on drag when dirty — we intercept via onOpenChange
86
+ >
87
+ <Drawer.Portal>
88
+ <Drawer.Overlay className="fixed inset-0 z-40 bg-black/40" />
89
+ <Drawer.Content
90
+ className="fixed inset-x-0 bottom-0 z-50 flex flex-col bg-canvas rounded-t-lg"
91
+ style={{ height: "95dvh" }}
92
+ >
93
+ <Drawer.Handle className="mx-auto mt-2 mb-1 h-1.5 w-12 rounded-full bg-fg-muted/30" />
94
+
95
+ <Drawer.Title className="px-4 py-2 text-base font-semibold border-b border-line">
96
+ {title}
97
+ </Drawer.Title>
98
+
99
+ {isConfigError ? (
100
+ <div className="flex-1 flex items-center justify-center p-4">
101
+ <ErrorState
102
+ title="Couldn't load compose"
103
+ error={configError}
104
+ onRetry={() => {
105
+ refetchConfig();
106
+ }}
107
+ />
108
+ </div>
109
+ ) : (
110
+ <div className="flex-1 overflow-hidden">
111
+ <ComposeForm
112
+ mode={state.mode}
113
+ account={state.account}
114
+ sourceMessage={state.sourceMessage}
115
+ onClose={closeCompose}
116
+ />
117
+ </div>
118
+ )}
119
+ </Drawer.Content>
120
+ </Drawer.Portal>
121
+
122
+ {/* Confirmation dialog when dismissing dirty draft */}
123
+ {showConfirm && (
124
+ <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
125
+ <div className="mx-4 w-full max-w-sm rounded-xl bg-canvas p-6 shadow-xl">
126
+ <h3 className="text-lg font-semibold">Discard draft?</h3>
127
+ <p className="mt-2 text-sm text-fg-muted">
128
+ Your message has unsaved content. Are you sure you want to discard
129
+ it?
130
+ </p>
131
+ <div className="mt-4 flex justify-end gap-2">
132
+ <button
133
+ type="button"
134
+ onClick={handleCancelDiscard}
135
+ className="rounded-md px-4 py-2 text-sm font-medium hover:bg-surface-raised transition-colors"
136
+ >
137
+ Keep editing
138
+ </button>
139
+ <button
140
+ type="button"
141
+ onClick={handleConfirmDiscard}
142
+ className="rounded-md bg-danger px-4 py-2 text-sm font-medium text-canvas hover:bg-danger/90 transition-colors"
143
+ >
144
+ Discard
145
+ </button>
146
+ </div>
147
+ </div>
148
+ </div>
149
+ )}
150
+ </Drawer.Root>
151
+ );
152
+ };
@@ -0,0 +1,86 @@
1
+ import { AutoformatPlugin } from "@platejs/autoformat";
2
+ import {
3
+ BlockquotePlugin,
4
+ BoldPlugin,
5
+ ItalicPlugin,
6
+ } from "@platejs/basic-nodes/react";
7
+ import { LinkPlugin } from "@platejs/link/react";
8
+ import type { Value } from "platejs";
9
+ import { PlateContent, usePlateEditor } from "platejs/react";
10
+ import { useEffect } from "react";
11
+
12
+ export interface PlateEditorProps {
13
+ initialValue?: Value;
14
+ onChange?: (value: Value) => void;
15
+ onSubmit?: () => void;
16
+ autoFocus?: boolean;
17
+ }
18
+
19
+ const EMPTY_VALUE: Value = [{ type: "p", children: [{ text: "" }] }];
20
+
21
+ export const COMPOSE_PLUGINS = [
22
+ BoldPlugin,
23
+ ItalicPlugin,
24
+ LinkPlugin,
25
+ BlockquotePlugin,
26
+ AutoformatPlugin.configure({
27
+ options: {
28
+ rules: [
29
+ {
30
+ match: "**",
31
+ mode: "mark" as const,
32
+ type: "bold",
33
+ },
34
+ {
35
+ match: "*",
36
+ mode: "mark" as const,
37
+ type: "italic",
38
+ },
39
+ {
40
+ match: "> ",
41
+ mode: "block" as const,
42
+ type: "blockquote",
43
+ },
44
+ ],
45
+ enableUndoOnDelete: true,
46
+ },
47
+ }),
48
+ ];
49
+
50
+ export const usePlateComposeEditor = (initialValue?: Value) =>
51
+ usePlateEditor({
52
+ plugins: COMPOSE_PLUGINS,
53
+ value: initialValue ?? EMPTY_VALUE,
54
+ });
55
+
56
+ export const PlateEditorContent = ({
57
+ onSubmit,
58
+ autoFocus,
59
+ editor,
60
+ }: Omit<PlateEditorProps, "initialValue" | "onChange"> & {
61
+ editor: ReturnType<typeof usePlateComposeEditor>;
62
+ }) => {
63
+ useEffect(() => {
64
+ if (autoFocus) {
65
+ const timer = setTimeout(() => {
66
+ editor.tf.focus();
67
+ }, 0);
68
+ return () => clearTimeout(timer);
69
+ }
70
+ }, [autoFocus, editor]);
71
+
72
+ const handleKeyDown = (e: React.KeyboardEvent) => {
73
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && onSubmit) {
74
+ e.preventDefault();
75
+ onSubmit();
76
+ }
77
+ };
78
+
79
+ return (
80
+ <PlateContent
81
+ className="w-full px-3 py-2 bg-canvas text-sm outline-none min-h-[120px] [&_blockquote]:pl-3 [&_blockquote]:border-l-2 [&_blockquote]:border-fg-subtle/30 [&_blockquote]:text-fg-muted [&_a]:text-accent [&_a]:underline"
82
+ placeholder="Write your message..."
83
+ onKeyDown={handleKeyDown}
84
+ />
85
+ );
86
+ };