@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,110 @@
1
+ import {
2
+ BlockquotePlugin,
3
+ BoldPlugin,
4
+ ItalicPlugin,
5
+ } from "@platejs/basic-nodes/react";
6
+ import { insertLink } from "@platejs/link";
7
+ import { Bold, Italic, Link, Quote, Redo2, Undo2 } from "lucide-react";
8
+ import { useEditorRef, useEditorSelector } from "platejs/react";
9
+
10
+ const ToolbarButton = ({
11
+ isActive,
12
+ onClick,
13
+ children,
14
+ title,
15
+ }: {
16
+ isActive: boolean;
17
+ onClick: () => void;
18
+ children: React.ReactNode;
19
+ title: string;
20
+ }) => (
21
+ <button
22
+ type="button"
23
+ onMouseDown={(e) => {
24
+ e.preventDefault();
25
+ onClick();
26
+ }}
27
+ title={title}
28
+ className={`p-1.5 rounded transition-colors ${
29
+ isActive
30
+ ? "text-fg bg-accent-2-soft"
31
+ : "text-fg-muted hover:text-fg hover:bg-surface-raised"
32
+ }`}
33
+ >
34
+ {children}
35
+ </button>
36
+ );
37
+
38
+ export const PlateToolbar = () => {
39
+ const editor = useEditorRef();
40
+
41
+ const isBoldActive = useEditorSelector(
42
+ (editor) => !!editor.api.mark(BoldPlugin.key),
43
+ [],
44
+ );
45
+ const isItalicActive = useEditorSelector(
46
+ (editor) => !!editor.api.mark(ItalicPlugin.key),
47
+ [],
48
+ );
49
+ const isBlockquoteActive = useEditorSelector((editor) => {
50
+ const entry = editor.api.block();
51
+ return entry ? entry[0].type === BlockquotePlugin.key : false;
52
+ }, []);
53
+
54
+ const canUndo = useEditorSelector(
55
+ (editor) => editor.history.undos.length > 0,
56
+ [],
57
+ );
58
+ const canRedo = useEditorSelector(
59
+ (editor) => editor.history.redos.length > 0,
60
+ [],
61
+ );
62
+
63
+ return (
64
+ <div className="flex items-center gap-0.5 px-3 py-1 border-b border-line">
65
+ <ToolbarButton
66
+ isActive={isBoldActive}
67
+ onClick={() => editor.tf.toggleMark(BoldPlugin.key)}
68
+ title="Bold (Ctrl+B)"
69
+ >
70
+ <Bold className="size-4" />
71
+ </ToolbarButton>
72
+ <ToolbarButton
73
+ isActive={isItalicActive}
74
+ onClick={() => editor.tf.toggleMark(ItalicPlugin.key)}
75
+ title="Italic (Ctrl+I)"
76
+ >
77
+ <Italic className="size-4" />
78
+ </ToolbarButton>
79
+ <ToolbarButton
80
+ isActive={false}
81
+ onClick={() => insertLink(editor, { url: "" })}
82
+ title="Link (Ctrl+K)"
83
+ >
84
+ <Link className="size-4" />
85
+ </ToolbarButton>
86
+ <ToolbarButton
87
+ isActive={isBlockquoteActive}
88
+ onClick={() => editor.tf.toggleBlock(BlockquotePlugin.key)}
89
+ title="Blockquote"
90
+ >
91
+ <Quote className="size-4" />
92
+ </ToolbarButton>
93
+ <div className="mx-1.5 h-4 w-px bg-line" />
94
+ <ToolbarButton
95
+ isActive={false}
96
+ onClick={() => editor.undo()}
97
+ title="Undo (Ctrl+Z)"
98
+ >
99
+ <Undo2 className={`size-4 ${!canUndo ? "opacity-40" : ""}`} />
100
+ </ToolbarButton>
101
+ <ToolbarButton
102
+ isActive={false}
103
+ onClick={() => editor.redo()}
104
+ title="Redo (Ctrl+Y)"
105
+ >
106
+ <Redo2 className={`size-4 ${!canRedo ? "opacity-40" : ""}`} />
107
+ </ToolbarButton>
108
+ </div>
109
+ );
110
+ };
@@ -0,0 +1,19 @@
1
+ interface SubjectFieldProps {
2
+ value: string;
3
+ onChange: (value: string) => void;
4
+ }
5
+
6
+ export const SubjectField = ({ value, onChange }: SubjectFieldProps) => (
7
+ <div className="flex items-start gap-2">
8
+ {/* biome-ignore lint/a11y/noLabelWithoutControl: label is visually adjacent to the sibling input; static id risks duplicates */}
9
+ <label className="text-sm text-fg-muted shrink-0 w-12 pt-1.5">Subj:</label>
10
+ <input
11
+ type="text"
12
+ value={value}
13
+ onChange={(e) => onChange(e.target.value)}
14
+ className="flex-1 px-2 py-1.5 border rounded-md bg-canvas text-sm"
15
+ placeholder="Subject"
16
+ data-subject-field
17
+ />
18
+ </div>
19
+ );
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Draft-discard must surface a banner on a soft failure.
3
+ *
4
+ * `ComposeForm.handleDiscard` closes the compose dialog optimistically, then
5
+ * fires the delete mutation. Before #741 the delete had no `onError`, so a soft
6
+ * 4xx (409/404 "the draft is already gone") closed the dialog as if it
7
+ * succeeded — silent discard. The fix adds an `onError` that pushes a banner via
8
+ * `buildMutationErrorBanner("Couldn't discard draft", …)`, mirroring the
9
+ * outbox-delete pattern. A fatal 5xx still escalates globally; this pins the
10
+ * non-fatal branch.
11
+ *
12
+ * Exercising the real mutation `onError` headlessly (no DOM) via the same
13
+ * `MutationCache` wiring `ComposeForm` uses through `QueryClient`.
14
+ */
15
+
16
+ import assert from "node:assert/strict";
17
+ import { describe, it } from "node:test";
18
+ import { MutationCache, QueryClient } from "@tanstack/react-query";
19
+ import { ApiError } from "../../lib/api.js";
20
+ import {
21
+ buildMutationErrorBanner,
22
+ type PushErrorInput,
23
+ } from "../ui/error-banners.js";
24
+
25
+ describe("draft discard surfaces a banner on a soft failure (#741)", () => {
26
+ const buildDiscardMutation = (
27
+ pushError: (input: PushErrorInput) => void,
28
+ fail: () => Promise<never>,
29
+ ) => {
30
+ const client = new QueryClient({
31
+ mutationCache: new MutationCache(),
32
+ });
33
+ return client.getMutationCache().build(client, {
34
+ mutationFn: fail,
35
+ onError: (error: unknown) => {
36
+ pushError(
37
+ buildMutationErrorBanner(
38
+ "Couldn't discard draft",
39
+ "The draft wasn't discarded.",
40
+ error,
41
+ ),
42
+ );
43
+ },
44
+ });
45
+ };
46
+
47
+ it("pushes a 'Couldn't discard draft' banner on a 409 already-gone", async () => {
48
+ const pushed: PushErrorInput[] = [];
49
+ const mutation = buildDiscardMutation(
50
+ (input) => pushed.push(input),
51
+ async () => {
52
+ throw new ApiError("Draft already deleted", 409);
53
+ },
54
+ );
55
+
56
+ await mutation.execute(undefined).catch(() => {});
57
+
58
+ assert.equal(pushed.length, 1);
59
+ assert.equal(pushed[0].title, "Couldn't discard draft");
60
+ assert.equal(pushed[0].detail, "Draft already deleted");
61
+ });
62
+
63
+ it("falls back to a human detail when the error carries no message", async () => {
64
+ const pushed: PushErrorInput[] = [];
65
+ const mutation = buildDiscardMutation(
66
+ (input) => pushed.push(input),
67
+ async () => {
68
+ throw {};
69
+ },
70
+ );
71
+
72
+ await mutation.execute(undefined).catch(() => {});
73
+
74
+ assert.equal(pushed[0].detail, "The draft wasn't discarded.");
75
+ });
76
+ });
@@ -0,0 +1,6 @@
1
+ export { ComposeForm } from "./ComposeForm.js";
2
+ export type { ComposeMode, ComposeState } from "./ComposeProvider.js";
3
+ export { ComposeProvider, useCompose } from "./ComposeProvider.js";
4
+ export { FullCompose } from "./FullCompose.js";
5
+ export { InlineCompose } from "./InlineCompose.js";
6
+ export { MobileComposeSheet } from "./MobileComposeSheet.js";
@@ -0,0 +1,23 @@
1
+ import DOMPurify from "dompurify";
2
+
3
+ const QUOTE_ALLOWED_TAGS = [
4
+ "p",
5
+ "br",
6
+ "strong",
7
+ "b",
8
+ "em",
9
+ "i",
10
+ "a",
11
+ "blockquote",
12
+ "ul",
13
+ "ol",
14
+ "li",
15
+ ];
16
+
17
+ const QUOTE_ALLOWED_ATTR = ["href"];
18
+
19
+ export const sanitizeQuoteHtml = (html: string): string =>
20
+ DOMPurify.sanitize(html, {
21
+ ALLOWED_TAGS: QUOTE_ALLOWED_TAGS,
22
+ ALLOWED_ATTR: QUOTE_ALLOWED_ATTR,
23
+ });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * AppShellSkeleton — structural placeholder shown on cold load.
3
+ *
4
+ * Renders the inbox chrome (nav rail + list rows) immediately, before the
5
+ * auth session hydrates and the config query resolves, so the user sees the
6
+ * shape of the app instead of a blank screen or a "Loading..." string. The
7
+ * real list components swap in once their own data arrives.
8
+ */
9
+
10
+ const NavRailSkeleton = () => (
11
+ <div className="hidden h-full w-[17%] min-w-40 max-w-64 shrink-0 flex-col gap-1 border-r border-line p-3 md:flex">
12
+ <div className="mb-3 h-6 w-24 rounded bg-surface-sunken" />
13
+ {Array.from({ length: 6 }).map((_, i) => (
14
+ <div
15
+ // biome-ignore lint/suspicious/noArrayIndexKey: skeleton rows have no stable id
16
+ key={i}
17
+ className="flex items-center gap-2 py-1.5"
18
+ >
19
+ <div className="size-5 shrink-0 rounded bg-surface-sunken" />
20
+ <div className="h-3.5 flex-1 rounded bg-surface-sunken" />
21
+ </div>
22
+ ))}
23
+ </div>
24
+ );
25
+
26
+ const ListRowsSkeleton = () => (
27
+ <div className="min-w-0 flex-1">
28
+ {Array.from({ length: 8 }).map((_, i) => (
29
+ <div
30
+ // biome-ignore lint/suspicious/noArrayIndexKey: skeleton rows have no stable id
31
+ key={i}
32
+ className="flex items-start gap-3 border-b border-line py-2 pl-5 pr-4"
33
+ >
34
+ <div className="mt-0.5 size-7 shrink-0 rounded-full bg-surface-sunken" />
35
+ <div className="flex-1 space-y-1.5">
36
+ <div className="flex justify-between gap-2">
37
+ <div className="h-3.5 w-28 rounded bg-surface-sunken" />
38
+ <div className="h-3 w-12 rounded bg-surface-sunken" />
39
+ </div>
40
+ <div className="h-3.5 w-48 rounded bg-surface-sunken" />
41
+ <div className="h-3 w-full rounded bg-surface-sunken" />
42
+ </div>
43
+ </div>
44
+ ))}
45
+ </div>
46
+ );
47
+
48
+ export const AppShellSkeleton = () => (
49
+ <div className="flex h-dvh animate-pulse bg-canvas" aria-hidden="true">
50
+ <NavRailSkeleton />
51
+ <ListRowsSkeleton />
52
+ </div>
53
+ );
@@ -0,0 +1,86 @@
1
+ import { useLocation, useNavigate } from "@tanstack/react-router";
2
+ import { Pencil } from "lucide-react";
3
+ import { startTransition } from "react";
4
+ import { useCompose } from "@/components/compose/ComposeProvider";
5
+
6
+ /**
7
+ * Returns true when `pathname` is `/mail/<id>` for any id other than
8
+ * the reserved `outbox` segment. That route owns a `FullCompose` mount
9
+ * point, so the FAB can open compose in-place. From every other route
10
+ * the FAB has to send the user there first.
11
+ */
12
+ const isOnMailboxRoute = (pathname: string): boolean =>
13
+ /^\/mail\/(?!outbox)[^/?]+/.test(pathname);
14
+
15
+ /**
16
+ * Primary mobile surfaces where the FAB belongs: anywhere under
17
+ * `/mail` or under `/settings`. The bare `/` route (sign-in / OAuth
18
+ * landing) is intentionally excluded — compose has no useful target
19
+ * before the user has an account.
20
+ */
21
+ const isOnPrimaryMobileRoute = (pathname: string): boolean =>
22
+ pathname.startsWith("/mail") || pathname.startsWith("/settings");
23
+
24
+ /**
25
+ * Floating Action Button for composing a new message. Mobile-only.
26
+ *
27
+ * Layout follows Material 3: 56×56 surface, 16px from the right and
28
+ * bottom edges (plus the iOS safe-area inset). Hidden when any of:
29
+ * - Viewport is `≥ md` (desktop has compose in the sidebar / etc.).
30
+ * - The compose surface is already open.
31
+ * - The user is reading a thread (`?selectedMessageId=…`) — the
32
+ * conversation's Reply/Forward action bar covers that workflow.
33
+ * - The user is not on a primary mobile route (`/mail` or
34
+ * `/settings`).
35
+ *
36
+ * From routes that don't host a `FullCompose` (everything except
37
+ * `/mail/<mailboxId>` — i.e. `/mail`, `/mail/outbox`, `/settings/*`)
38
+ * tapping the FAB opens compose state and navigates to `/mail`, which
39
+ * the index loader redirects to the preferred mailbox (PR #138). The
40
+ * compose state survives the route transition because `ComposeProvider`
41
+ * lives in `__root.tsx`, so the destination route mounts straight into
42
+ * compose.
43
+ */
44
+ export const ComposeFab = () => {
45
+ const { state, openCompose } = useCompose();
46
+ const location = useLocation();
47
+ const navigate = useNavigate();
48
+
49
+ const search = location.search as Record<string, unknown> | undefined;
50
+ const isReadingThread =
51
+ typeof search?.selectedMessageId === "string" &&
52
+ search.selectedMessageId.length > 0;
53
+
54
+ if (
55
+ !isOnPrimaryMobileRoute(location.pathname) ||
56
+ state.isOpen ||
57
+ isReadingThread
58
+ )
59
+ return null;
60
+
61
+ const handleClick = () => {
62
+ // Wrap in startTransition so the suspension that ComposeForm
63
+ // triggers on first mount (lazy chunks, queries) doesn't
64
+ // snap the surrounding chrome into a route-level fallback.
65
+ startTransition(() => {
66
+ openCompose({ mode: "new" });
67
+ });
68
+ if (isOnMailboxRoute(location.pathname)) return;
69
+ navigate({ to: "/mail" });
70
+ };
71
+
72
+ return (
73
+ <button
74
+ type="button"
75
+ onClick={handleClick}
76
+ aria-label="Compose new message"
77
+ className="lg:hidden fixed right-4 z-30 h-14 w-14 rounded-full bg-accent text-accent-fg shadow-lg flex items-center justify-center hover:opacity-90 active:scale-95 transition-all"
78
+ style={{
79
+ // 16px breathing room above the iOS home-indicator inset.
80
+ bottom: "calc(env(safe-area-inset-bottom, 0) + 1rem)",
81
+ }}
82
+ >
83
+ <Pencil className="size-6" />
84
+ </button>
85
+ );
86
+ };
@@ -0,0 +1,105 @@
1
+ import { X } from "lucide-react";
2
+ import { type ReactNode, useEffect, useRef } from "react";
3
+ import { cn } from "@/lib/utils";
4
+
5
+ interface DrawerProps {
6
+ isOpen: boolean;
7
+ onClose: () => void;
8
+ children: ReactNode;
9
+ ariaLabel?: string;
10
+ side?: "left" | "right";
11
+ widthClassName?: string;
12
+ }
13
+
14
+ /**
15
+ * Modal navigation drawer. Slides in from the side with a scrim behind.
16
+ * Dismissed by scrim tap, escape key, or the close button. Focus moves into
17
+ * the drawer on open and returns to the previously focused element on close.
18
+ */
19
+ export const Drawer = ({
20
+ isOpen,
21
+ onClose,
22
+ children,
23
+ ariaLabel = "Navigation",
24
+ side = "left",
25
+ widthClassName = "w-[80vw] max-w-[320px]",
26
+ }: DrawerProps) => {
27
+ const drawerRef = useRef<HTMLDivElement>(null);
28
+ const previouslyFocusedRef = useRef<HTMLElement | null>(null);
29
+
30
+ useEffect(() => {
31
+ if (!isOpen) return;
32
+
33
+ previouslyFocusedRef.current = document.activeElement as HTMLElement | null;
34
+
35
+ const handleKey = (event: KeyboardEvent) => {
36
+ if (event.key === "Escape") {
37
+ event.preventDefault();
38
+ onClose();
39
+ }
40
+ };
41
+
42
+ document.addEventListener("keydown", handleKey);
43
+
44
+ // Move focus into the drawer
45
+ const focusable = drawerRef.current?.querySelector<HTMLElement>(
46
+ "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])",
47
+ );
48
+ focusable?.focus();
49
+
50
+ // Lock body scroll
51
+ const previousOverflow = document.body.style.overflow;
52
+ document.body.style.overflow = "hidden";
53
+
54
+ return () => {
55
+ document.removeEventListener("keydown", handleKey);
56
+ document.body.style.overflow = previousOverflow;
57
+ previouslyFocusedRef.current?.focus();
58
+ };
59
+ }, [isOpen, onClose]);
60
+
61
+ if (!isOpen) return null;
62
+
63
+ const sideClasses =
64
+ side === "left"
65
+ ? "left-0 border-r animate-in slide-in-from-left duration-150"
66
+ : "right-0 border-l animate-in slide-in-from-right duration-150";
67
+
68
+ return (
69
+ <div
70
+ className="fixed inset-0 z-50 lg:hidden"
71
+ role="dialog"
72
+ aria-modal="true"
73
+ aria-label={ariaLabel}
74
+ >
75
+ {/* Scrim */}
76
+ <button
77
+ type="button"
78
+ aria-label="Close menu"
79
+ onClick={onClose}
80
+ className="absolute inset-0 bg-black/40 animate-in fade-in duration-150 cursor-default"
81
+ />
82
+ {/* Drawer panel */}
83
+ <div
84
+ ref={drawerRef}
85
+ className={cn(
86
+ "absolute top-0 bottom-0 bg-canvas border-line shadow-xl flex flex-col",
87
+ widthClassName,
88
+ sideClasses,
89
+ )}
90
+ >
91
+ <div className="flex items-center justify-end h-12 px-2 border-b border-line shrink-0">
92
+ <button
93
+ type="button"
94
+ onClick={onClose}
95
+ className="p-2 rounded-md hover:bg-surface-raised transition-colors min-h-11 min-w-11 inline-flex items-center justify-center"
96
+ aria-label="Close menu"
97
+ >
98
+ <X className="size-5" />
99
+ </button>
100
+ </div>
101
+ <div className="flex-1 overflow-y-auto">{children}</div>
102
+ </div>
103
+ </div>
104
+ );
105
+ };
@@ -0,0 +1,48 @@
1
+ import type { RemitImapAutoMovedInfo } from "@remit/api-http-client/types.gen.ts";
2
+ import { AutoMovedBadge } from "@remit/ui";
3
+ import { useAutoMovedBadge } from "@/hooks/useAutoMovedBadge";
4
+
5
+ interface AutoMovedIndicatorProps {
6
+ accountId: string | undefined;
7
+ messageId: string;
8
+ threadId: string;
9
+ mailboxId: string;
10
+ autoMoved: RemitImapAutoMovedInfo | undefined;
11
+ /** `md` (reading view) adds the inline Undo action; `sm` (list row) is icon + label only. */
12
+ size?: "sm" | "md";
13
+ }
14
+
15
+ /**
16
+ * Renders the "auto-moved by Remit" badge only while the move is still in
17
+ * effect (current mailbox matches the verdict's implied destination) — see
18
+ * `useAutoMovedBadge`. Mount this only when `autoMoved` is present on the row
19
+ * (callers gate with `thread.autoMoved &&`) so rows without a move never pay
20
+ * for the Inbox/Junk mailbox lookups.
21
+ */
22
+ export function AutoMovedIndicator({
23
+ accountId,
24
+ messageId,
25
+ threadId,
26
+ mailboxId,
27
+ autoMoved,
28
+ size = "sm",
29
+ }: AutoMovedIndicatorProps) {
30
+ const badge = useAutoMovedBadge({
31
+ accountId,
32
+ messageId,
33
+ threadId,
34
+ mailboxId,
35
+ autoMoved,
36
+ });
37
+
38
+ if (!badge.show) return null;
39
+
40
+ return (
41
+ <AutoMovedBadge
42
+ label={badge.label}
43
+ size={size}
44
+ onUndo={badge.onUndo}
45
+ undoLabel={badge.isUndoing ? "Undoing…" : "Undo"}
46
+ />
47
+ );
48
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * React-render smoke test for `BlockedImagesNotice` — the privacy "images
3
+ * blocked" bar extracted from `MessageBody`. Pins the never-disable-buttons
4
+ * rule (#943): the "Always trust" button stays active while the trust toggle
5
+ * is in flight. It must NOT render `disabled`; the in-flight state is reflected
6
+ * via `aria-busy` and a pending label instead, so the control stays focusable
7
+ * and clickable (the handler no-ops the second click on the consumer side).
8
+ *
9
+ * Uses `react-dom/server`'s `renderToString` so the assertion runs without
10
+ * jsdom/happy-dom (no new deps), matching `MessageBodyErrorBanner.render.test`.
11
+ */
12
+
13
+ import assert from "node:assert/strict";
14
+ import { describe, it } from "node:test";
15
+ import { createElement } from "react";
16
+ import { renderToString } from "react-dom/server";
17
+ import { BlockedImagesNotice } from "./BlockedImagesNotice";
18
+
19
+ const noop = () => undefined;
20
+
21
+ const render = (
22
+ overrides: Partial<Parameters<typeof BlockedImagesNotice>[0]>,
23
+ ) =>
24
+ renderToString(
25
+ createElement(BlockedImagesNotice, {
26
+ blockedImageCount: 3,
27
+ canAlwaysTrust: true,
28
+ isTrustPending: false,
29
+ onLoadOnce: noop,
30
+ onAlwaysTrust: noop,
31
+ ...overrides,
32
+ }) as never,
33
+ );
34
+
35
+ const alwaysTrustButton = (html: string): string | undefined =>
36
+ html.match(/<button\b[^>]*>(?:Always trust|Trusting…)<\/button>/)?.[0];
37
+
38
+ describe("BlockedImagesNotice — never-disable the Always trust button (#943)", () => {
39
+ it("renders the Always trust button active when not pending", () => {
40
+ const html = render({ isTrustPending: false });
41
+ const btn = alwaysTrustButton(html);
42
+ assert.ok(btn, "Always trust button should render");
43
+ assert.doesNotMatch(btn, /\bdisabled\b/);
44
+ assert.match(btn, /Always trust/);
45
+ });
46
+
47
+ it("keeps the Always trust button NOT disabled while pending, reflecting state via aria-busy + label", () => {
48
+ const html = render({ isTrustPending: true });
49
+ const btn = alwaysTrustButton(html);
50
+ assert.ok(btn, "Always trust button should still render while pending");
51
+ assert.doesNotMatch(
52
+ btn,
53
+ /\bdisabled\b/,
54
+ "never disable the button — pending must not gray it out (#943)",
55
+ );
56
+ assert.match(btn, /aria-busy="true"/);
57
+ assert.match(btn, /Trusting…/);
58
+ });
59
+
60
+ it("hides the Always trust button when the sender can't be trusted", () => {
61
+ const html = render({ canAlwaysTrust: false });
62
+ assert.equal(alwaysTrustButton(html), undefined);
63
+ assert.match(html, /<button\b[^>]*>Load once<\/button>/);
64
+ });
65
+ });
@@ -0,0 +1,41 @@
1
+ interface BlockedImagesNoticeProps {
2
+ blockedImageCount: number;
3
+ canAlwaysTrust: boolean;
4
+ isTrustPending: boolean;
5
+ onLoadOnce: () => void;
6
+ onAlwaysTrust: () => void;
7
+ }
8
+
9
+ export const BlockedImagesNotice = ({
10
+ blockedImageCount,
11
+ canAlwaysTrust,
12
+ isTrustPending,
13
+ onLoadOnce,
14
+ onAlwaysTrust,
15
+ }: BlockedImagesNoticeProps) => (
16
+ <div className="mb-3 flex items-center justify-between rounded-md bg-surface-sunken/50 px-3 py-2 text-sm">
17
+ <span className="text-fg-muted">
18
+ {blockedImageCount} image{blockedImageCount > 1 ? "s" : ""} blocked for
19
+ privacy
20
+ </span>
21
+ <div className="flex items-center gap-3">
22
+ <button
23
+ type="button"
24
+ onClick={onLoadOnce}
25
+ className="text-accent hover:underline"
26
+ >
27
+ Load once
28
+ </button>
29
+ {canAlwaysTrust && (
30
+ <button
31
+ type="button"
32
+ onClick={onAlwaysTrust}
33
+ aria-busy={isTrustPending}
34
+ className="text-accent hover:underline aria-busy:opacity-50"
35
+ >
36
+ {isTrustPending ? "Trusting…" : "Always trust"}
37
+ </button>
38
+ )}
39
+ </div>
40
+ </div>
41
+ );