@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,139 @@
1
+ import { Loader2, MailOpen, Sparkles, Trash2, X } from "lucide-react";
2
+ import { cn } from "@/lib/utils";
3
+ import { MoveToTrigger } from "./MoveToTrigger";
4
+
5
+ interface SelectionToolbarProps {
6
+ selectedCount: number;
7
+ onDelete: () => void;
8
+ onClearSelection: () => void;
9
+ onMarkAsRead?: () => void;
10
+ onMove?: (destinationMailboxId: string) => void;
11
+ /**
12
+ * Open the smart-organize flow for the current selection. Rendered only
13
+ * when a single-account move target is resolvable (same guard as Move) —
14
+ * organize is account-scoped.
15
+ */
16
+ onOrganize?: () => void;
17
+ isDeleting?: boolean;
18
+ /**
19
+ * True while a move mutation is in flight. Action buttons no-op while
20
+ * busy — never disabled.
21
+ */
22
+ isMoving?: boolean;
23
+ /**
24
+ * Owning account for the current mailbox view. Required to scope the
25
+ * Move-to-folder picker. The Move button only renders when both
26
+ * `onMove` and `accountId` are present.
27
+ */
28
+ accountId?: string;
29
+ currentMailboxId?: string;
30
+ /**
31
+ * When the user's selection spans multiple accounts the toolbar
32
+ * disables Move and surfaces this hint inline next to the button.
33
+ * Move only works within one account.
34
+ */
35
+ moveDisabledHint?: string;
36
+ }
37
+
38
+ export const SelectionToolbar = ({
39
+ selectedCount,
40
+ onDelete,
41
+ onClearSelection,
42
+ onMarkAsRead,
43
+ onMove,
44
+ onOrganize,
45
+ isDeleting = false,
46
+ isMoving = false,
47
+ accountId,
48
+ currentMailboxId,
49
+ moveDisabledHint,
50
+ }: SelectionToolbarProps) => {
51
+ const isBusy = isDeleting || isMoving;
52
+ if (selectedCount === 0) return null;
53
+
54
+ const canShowMove = !!onMove && !!accountId && !!currentMailboxId;
55
+ const canShowOrganize =
56
+ !!onOrganize && !!accountId && !!currentMailboxId && !moveDisabledHint;
57
+
58
+ return (
59
+ <div className="sticky top-0 z-10 flex items-center justify-between px-3 py-2 bg-surface-sunken border-b border-line">
60
+ <div className="flex items-center gap-3">
61
+ <button
62
+ type="button"
63
+ onClick={onClearSelection}
64
+ className="min-h-11 min-w-11 inline-flex items-center justify-center rounded hover:bg-surface-raised transition-colors"
65
+ aria-label="Clear selection"
66
+ >
67
+ <X className="size-4 text-fg-muted" />
68
+ </button>
69
+ <span className="text-sm font-medium">
70
+ {selectedCount} {selectedCount === 1 ? "message" : "messages"}{" "}
71
+ selected
72
+ </span>
73
+ {moveDisabledHint && (
74
+ // biome-ignore lint/a11y/useSemanticElements: <span> with role="status" keeps inline layout; <output> would shift flow
75
+ <span
76
+ className="text-xs text-fg-muted"
77
+ role="status"
78
+ aria-live="polite"
79
+ >
80
+ {moveDisabledHint}
81
+ </span>
82
+ )}
83
+ </div>
84
+ <div className="flex items-center gap-1">
85
+ {canShowOrganize && onOrganize && (
86
+ <button
87
+ type="button"
88
+ onClick={isBusy ? undefined : onOrganize}
89
+ className="min-h-11 inline-flex items-center justify-center gap-1.5 px-3 rounded text-sm font-medium transition-colors hover:bg-surface-raised"
90
+ aria-label="Organize similar messages"
91
+ >
92
+ <Sparkles className="size-4" />
93
+ <span className="hidden sm:inline">Organize</span>
94
+ </button>
95
+ )}
96
+ {onMarkAsRead && (
97
+ <button
98
+ type="button"
99
+ onClick={isBusy ? undefined : onMarkAsRead}
100
+ className="min-h-11 min-w-11 inline-flex items-center justify-center rounded text-sm font-medium transition-colors hover:bg-surface-raised"
101
+ aria-label="Mark as read"
102
+ aria-busy={isBusy}
103
+ >
104
+ <MailOpen className="size-4" />
105
+ </button>
106
+ )}
107
+ {canShowMove && onMove && accountId && currentMailboxId && (
108
+ <MoveToTrigger
109
+ accountId={accountId}
110
+ currentMailboxId={currentMailboxId}
111
+ onMove={isBusy ? () => {} : onMove}
112
+ disabledHint={moveDisabledHint}
113
+ variant="icon-only"
114
+ label="Move selected messages"
115
+ />
116
+ )}
117
+ <button
118
+ type="button"
119
+ onClick={isBusy ? undefined : onDelete}
120
+ className={cn(
121
+ "min-h-11 min-w-11 inline-flex items-center justify-center gap-1.5 px-3 rounded text-sm font-medium transition-colors",
122
+ "bg-danger text-canvas hover:bg-danger/90",
123
+ )}
124
+ aria-label="Delete selected messages"
125
+ aria-busy={isDeleting}
126
+ >
127
+ {isDeleting ? (
128
+ <Loader2 className="size-4 animate-spin" />
129
+ ) : (
130
+ <Trash2 className="size-4" />
131
+ )}
132
+ <span className="hidden sm:inline">
133
+ {isDeleting ? "Deleting..." : "Delete"}
134
+ </span>
135
+ </button>
136
+ </div>
137
+ </div>
138
+ );
139
+ };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The rescue banner is rendered by SpamRescue above the spam list. These tests
3
+ * cover the banner's rendering with a count sourced from the backend (via
4
+ * useRescueCandidates), exercising the component's output in isolation without
5
+ * app providers.
6
+ */
7
+
8
+ import assert from "node:assert/strict";
9
+ import { describe, it } from "node:test";
10
+ import { RescueBanner } from "@remit/ui";
11
+ import React, { createElement } from "react";
12
+ import { renderToString } from "react-dom/server";
13
+
14
+ (globalThis as { React?: typeof React }).React = React;
15
+
16
+ const noop = () => {};
17
+
18
+ const renderBanner = (count: number): string =>
19
+ renderToString(createElement(RescueBanner, { count, onReview: noop }));
20
+
21
+ describe("RescueBanner rendering", () => {
22
+ it("shows the candidate count for multiple messages", () => {
23
+ const html = renderBanner(2);
24
+ assert.match(html, /2 messages here/);
25
+ });
26
+
27
+ it("shows singular phrasing for a single message", () => {
28
+ const html = renderBanner(1);
29
+ assert.ok(html.includes("1"), "count should appear");
30
+ });
31
+
32
+ it("renders without throwing for zero count", () => {
33
+ assert.doesNotThrow(() => renderBanner(0));
34
+ });
35
+ });
@@ -0,0 +1,138 @@
1
+ import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import {
3
+ type MoveMailboxOption,
4
+ RescueBanner,
5
+ type RescueCandidate,
6
+ RescueFromSpamFlow,
7
+ } from "@remit/ui";
8
+ import { useQuery } from "@tanstack/react-query";
9
+ import {
10
+ type ReactNode,
11
+ useCallback,
12
+ useEffect,
13
+ useMemo,
14
+ useRef,
15
+ useState,
16
+ } from "react";
17
+ import { useTranslation } from "react-i18next";
18
+ import {
19
+ useFolderAppointments,
20
+ useInboxMailbox,
21
+ } from "@/hooks/useArchiveMailbox";
22
+ import { buildMailboxRoleMap, labelForMailbox } from "@/lib/folder-roles";
23
+ import { buildMoveTargets } from "@/lib/move-targets";
24
+ import {
25
+ recordRescueCandidatesSurfaced,
26
+ recordRescueCommitted,
27
+ recordRescueFlowOpened,
28
+ } from "@/lib/rescue-telemetry";
29
+ import { useTelemetry } from "@/lib/telemetry-context";
30
+
31
+ interface SpamRescueProps {
32
+ accountId: string;
33
+ currentMailboxId: string;
34
+ candidates: RescueCandidate[];
35
+ onMove: (messageIds: string[], destinationId: string) => void;
36
+ children: ReactNode;
37
+ }
38
+
39
+ /**
40
+ * Wraps the Spam folder list with the Rescue-from-Spam call-to-action and flow.
41
+ * Renders the banner above the list and the review sheet over it; the kit flow
42
+ * stays presentational, this component supplies the real folders, destination
43
+ * and move. Only mount it on the Spam folder when candidates exist.
44
+ */
45
+ export function SpamRescue({
46
+ accountId,
47
+ currentMailboxId,
48
+ candidates,
49
+ onMove,
50
+ children,
51
+ }: SpamRescueProps) {
52
+ const [open, setOpen] = useState(false);
53
+ const telemetry = useTelemetry();
54
+ const { t } = useTranslation("mail", { useSuspense: false });
55
+ const translator = useCallback(
56
+ (key: string, fallback: string): string =>
57
+ t(key, { defaultValue: fallback }),
58
+ [t],
59
+ );
60
+
61
+ const { inboxMailboxId } = useInboxMailbox(accountId);
62
+ const folderAppointments = useFolderAppointments(accountId);
63
+
64
+ const { data: mailboxesResponse } = useQuery({
65
+ ...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
66
+ staleTime: Infinity,
67
+ });
68
+
69
+ const folders = useMemo<MoveMailboxOption[]>(() => {
70
+ const targets = buildMoveTargets(
71
+ mailboxesResponse?.items ?? [],
72
+ folderAppointments,
73
+ );
74
+ const roleMap = buildMailboxRoleMap(folderAppointments);
75
+ return targets.map((mailbox) => ({
76
+ id: mailbox.mailboxId,
77
+ label: labelForMailbox(
78
+ mailbox,
79
+ roleMap.get(mailbox.mailboxId),
80
+ translator,
81
+ ),
82
+ searchValue: mailbox.fullPath,
83
+ isCurrent: mailbox.mailboxId === currentMailboxId,
84
+ }));
85
+ }, [
86
+ mailboxesResponse?.items,
87
+ folderAppointments,
88
+ currentMailboxId,
89
+ translator,
90
+ ]);
91
+
92
+ const defaultDestinationId = useMemo(() => {
93
+ if (inboxMailboxId) return inboxMailboxId;
94
+ const inbox = folders.find((f) => f.label.toLowerCase() === "inbox");
95
+ return inbox?.id ?? folders.find((f) => !f.isCurrent)?.id ?? "";
96
+ }, [inboxMailboxId, folders]);
97
+
98
+ const surfacedRef = useRef(false);
99
+ useEffect(() => {
100
+ if (surfacedRef.current) return;
101
+ surfacedRef.current = true;
102
+ recordRescueCandidatesSurfaced(telemetry, candidates.length);
103
+ }, [telemetry, candidates.length]);
104
+
105
+ const handleReview = useCallback(() => {
106
+ recordRescueFlowOpened(telemetry, candidates.length);
107
+ setOpen(true);
108
+ }, [telemetry, candidates.length]);
109
+
110
+ const handleConfirmMove = useCallback(
111
+ (messageIds: string[], destinationId: string) => {
112
+ recordRescueCommitted(telemetry, {
113
+ selected: messageIds.length,
114
+ total: candidates.length,
115
+ toInbox: destinationId === inboxMailboxId,
116
+ });
117
+ onMove(messageIds, destinationId);
118
+ },
119
+ [telemetry, candidates.length, inboxMailboxId, onMove],
120
+ );
121
+
122
+ return (
123
+ <div className="relative flex h-full min-h-0 flex-col">
124
+ <div className="shrink-0 px-row-inset pt-2">
125
+ <RescueBanner count={candidates.length} onReview={handleReview} />
126
+ </div>
127
+ <div className="min-h-0 flex-1">{children}</div>
128
+ <RescueFromSpamFlow
129
+ open={open}
130
+ candidates={candidates}
131
+ defaultDestinationId={defaultDestinationId}
132
+ availableFolders={folders}
133
+ onConfirmMove={handleConfirmMove}
134
+ onCancel={() => setOpen(false)}
135
+ />
136
+ </div>
137
+ );
138
+ }
@@ -0,0 +1,148 @@
1
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
2
+ import {
3
+ type Density,
4
+ SwipeableRow,
5
+ type SwipePeek,
6
+ type ThreadRowData,
7
+ } from "@remit/ui";
8
+ import { Link } from "@tanstack/react-router";
9
+ import { useCallback, useState } from "react";
10
+ import type { SelectionModifiers } from "@/hooks/useSelection";
11
+ import { toDisplayCategory } from "@/lib/display-category";
12
+ import { formatEmailDate } from "@/lib/format";
13
+ import { MessageListItem } from "./MessageListItem";
14
+
15
+ interface MailboxLinkSearch {
16
+ selectedMessageId?: string;
17
+ q?: string;
18
+ }
19
+
20
+ interface SwipeableMessageRowProps {
21
+ thread: RemitImapThreadMessageResponse;
22
+ mailboxId: string;
23
+ /** Owning account, forwarded to `MessageListItem` for the auto-moved badge's undo action. */
24
+ accountId?: string;
25
+ isSelected: boolean;
26
+ /** Roving keyboard focus cursor — renders the left accent rail (#429). */
27
+ isFocused?: boolean;
28
+ isChecked: boolean;
29
+ onToggleCheck: (id: string) => void;
30
+ onRowSelect: (messageId: string, modifiers: SelectionModifiers) => boolean;
31
+ isMultiSelectMode: boolean;
32
+ onLongPress: (messageId: string) => void;
33
+ isDesktop: boolean;
34
+ onDelete: (messageId: string) => void;
35
+ onToggleRead: (messageId: string, currentIsRead: boolean) => void;
36
+ density?: Density;
37
+ }
38
+
39
+ const toThreadRowData = (
40
+ thread: RemitImapThreadMessageResponse,
41
+ ): ThreadRowData => {
42
+ const suspicious = thread.authenticity?.dkimMismatch === true;
43
+ return {
44
+ id: thread.messageId,
45
+ accountId: thread.accountConfigId,
46
+ fromName: thread.fromName ?? thread.fromEmail ?? "Unknown",
47
+ fromEmail: thread.fromEmail ?? "",
48
+ subject: thread.subject ?? "(No subject)",
49
+ snippet: thread.snippet ?? "",
50
+ timeLabel: formatEmailDate(thread.sentDate),
51
+ isRead: thread.isRead,
52
+ hasAttachment: thread.hasAttachment,
53
+ starred:
54
+ thread.star != null && thread.star !== "none" && thread.hasStars === true,
55
+ trust: thread.senderTrust,
56
+ category: toDisplayCategory(thread.category),
57
+ suspicious,
58
+ };
59
+ };
60
+
61
+ export const SwipeableMessageRow = ({
62
+ thread,
63
+ mailboxId,
64
+ accountId,
65
+ isSelected,
66
+ isFocused,
67
+ isChecked,
68
+ onToggleCheck,
69
+ onRowSelect,
70
+ isMultiSelectMode,
71
+ onLongPress,
72
+ isDesktop,
73
+ onDelete,
74
+ onToggleRead,
75
+ density,
76
+ }: SwipeableMessageRowProps) => {
77
+ const [peek, setPeek] = useState<SwipePeek>("none");
78
+
79
+ const handleAct = useCallback(
80
+ (side: "leading" | "trailing") => {
81
+ navigator.vibrate?.(10);
82
+ if (side === "trailing") {
83
+ onDelete(thread.messageId);
84
+ } else {
85
+ onToggleRead(thread.messageId, thread.isRead);
86
+ }
87
+ setPeek("none");
88
+ },
89
+ [onDelete, onToggleRead, thread.messageId, thread.isRead],
90
+ );
91
+
92
+ const handleLongPress = useCallback(() => {
93
+ onLongPress(thread.messageId);
94
+ }, [onLongPress, thread.messageId]);
95
+
96
+ const handleToggleCheck = useCallback(() => {
97
+ onToggleCheck(thread.messageId);
98
+ }, [onToggleCheck, thread.messageId]);
99
+
100
+ if (isDesktop || isMultiSelectMode) {
101
+ return (
102
+ <MessageListItem
103
+ thread={thread}
104
+ mailboxId={mailboxId}
105
+ accountId={accountId}
106
+ isSelected={isSelected}
107
+ isFocused={isFocused}
108
+ isChecked={isChecked}
109
+ onToggleCheck={onToggleCheck}
110
+ onRowSelect={onRowSelect}
111
+ isMultiSelectMode={isMultiSelectMode}
112
+ onLongPress={onLongPress}
113
+ isDesktop={isDesktop}
114
+ density={density}
115
+ />
116
+ );
117
+ }
118
+
119
+ return (
120
+ <SwipeableRow
121
+ thread={toThreadRowData(thread)}
122
+ selectionMode={false}
123
+ checked={false}
124
+ active={isSelected}
125
+ peek={peek}
126
+ onPeek={setPeek}
127
+ onToggleCheck={handleToggleCheck}
128
+ onLongPress={handleLongPress}
129
+ onOpen={() => undefined}
130
+ onAct={handleAct}
131
+ linkComponent={({ onOpenClick, children, ...rowProps }) => (
132
+ <Link
133
+ {...rowProps}
134
+ to="/mail/$mailboxId"
135
+ params={{ mailboxId }}
136
+ search={(prev: MailboxLinkSearch) => ({
137
+ ...prev,
138
+ selectedMessageId: thread.messageId,
139
+ })}
140
+ data-message-row
141
+ onClick={onOpenClick}
142
+ >
143
+ {children}
144
+ </Link>
145
+ )}
146
+ />
147
+ );
148
+ };
@@ -0,0 +1,87 @@
1
+ import {
2
+ BodyFetchError,
3
+ type BodyFetchReason,
4
+ } from "@/hooks/useMessageBodyContent";
5
+
6
+ export interface BannerContent {
7
+ title: string;
8
+ detail: string;
9
+ }
10
+
11
+ /**
12
+ * Pure helpers for `MessageBodyErrorBanner`. Extracted so they can be
13
+ * unit-tested with the Node test runner without importing React /
14
+ * @aws-amplify/ui-react / lucide-react. The banner component composes these
15
+ * with the React surface.
16
+ *
17
+ * The variant copy lives here so a glance at one file shows every banner
18
+ * variant the SPA can render — issue #401 / postmortem #394.
19
+ */
20
+
21
+ /**
22
+ * Map a `BodyFetchReason` to user-facing banner copy.
23
+ *
24
+ * - `auth` — Lambda@Edge denied the JWT (missing/invalid/expired) or the
25
+ * tenant didn't match. The user needs to sign in again.
26
+ * - `body-missing` — CloudFront/S3 returned 403/404 without the edge reason
27
+ * header. The OAC bucket policy only grants `s3:GetObject` so a
28
+ * missing object surfaces as 403. Either an in-flight sync failure
29
+ * left the message stuck, or the body part was never written.
30
+ * - `content-type-mismatch` / `spa-shell-leak` — defensive guards; the user
31
+ * sees a safer "couldn't load" message and is asked to report it.
32
+ * - `generic` — anything else; surfaces the underlying error message so the
33
+ * user has something to attach to a support ticket.
34
+ */
35
+ export const contentForReason = (
36
+ reason: BodyFetchReason,
37
+ fallback: string,
38
+ ): BannerContent => {
39
+ switch (reason) {
40
+ case "auth":
41
+ return {
42
+ title: "Your session expired",
43
+ detail:
44
+ "Sign in again to reload the message body. Other parts of the app may also stop responding until you do.",
45
+ };
46
+ case "body-missing":
47
+ return {
48
+ title: "Message body is missing in storage",
49
+ detail:
50
+ "A sync failure may have left this message without its body bytes. Contact support, or run the reconcile script to refetch from the upstream IMAP server.",
51
+ };
52
+ case "content-type-mismatch":
53
+ return {
54
+ title: "Couldn't load message body",
55
+ detail:
56
+ "The server returned an unexpected content type for this part. The message body has not been rendered to keep you safe.",
57
+ };
58
+ case "spa-shell-leak":
59
+ return {
60
+ title: "Couldn't load message body",
61
+ detail:
62
+ "The server returned the app shell instead of the message body. This is an infrastructure bug — please report it.",
63
+ };
64
+ case "not-ready":
65
+ return {
66
+ title: "Message body is still syncing",
67
+ detail:
68
+ "This message's body hasn't finished downloading from the mail server yet. It should appear shortly — reopen the message to try again.",
69
+ };
70
+ default:
71
+ return {
72
+ title: "Couldn't load message body",
73
+ detail: fallback,
74
+ };
75
+ }
76
+ };
77
+
78
+ export const extractReason = (error: unknown): BodyFetchReason => {
79
+ if (error instanceof BodyFetchError) return error.reason;
80
+ return "generic";
81
+ };
82
+
83
+ export const extractFallbackDetail = (error: unknown): string => {
84
+ if (error instanceof Error) return error.message;
85
+ if (typeof error === "string") return error;
86
+ return "An unexpected error occurred while loading the message body.";
87
+ };
@@ -0,0 +1,37 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import React, { createElement } from "react";
5
+ import { renderToString } from "react-dom/server";
6
+ import { OrganizeDialog } from "./OrganizeDialog";
7
+
8
+ // The node test loader transpiles remit-ui's `.tsx` with the classic JSX
9
+ // runtime, which references a global `React`. Vite uses the automatic runtime,
10
+ // so this shim only exists for the SSR test harness.
11
+ (globalThis as { React?: typeof React }).React = React;
12
+
13
+ const render = (open: boolean) =>
14
+ renderToString(
15
+ createElement(
16
+ QueryClientProvider,
17
+ { client: new QueryClient() },
18
+ createElement(OrganizeDialog, {
19
+ open,
20
+ accountId: "acc-1",
21
+ mailboxId: "mbx-inbox",
22
+ selectedMessageIds: ["msg-1", "msg-2"],
23
+ onClose: () => undefined,
24
+ }),
25
+ ) as never,
26
+ );
27
+
28
+ describe("OrganizeDialog", () => {
29
+ it("renders nothing when closed", () => {
30
+ assert.equal(render(false), "");
31
+ });
32
+
33
+ it("shows the widen step while the preview is in flight", () => {
34
+ const html = render(true);
35
+ assert.match(html, /Finding similar messages/);
36
+ });
37
+ });
@@ -0,0 +1,83 @@
1
+ import { Button, Dialog } from "@remit/ui";
2
+ import { Loader2 } from "lucide-react";
3
+ import { useEffect } from "react";
4
+ import { useOrganizePreview } from "@/hooks/useOrganizePreview";
5
+ import { OrganizePanel } from "./OrganizePanel";
6
+
7
+ interface OrganizeDialogProps {
8
+ open: boolean;
9
+ accountId: string;
10
+ mailboxId: string;
11
+ selectedMessageIds: string[];
12
+ onClose: () => void;
13
+ }
14
+
15
+ /**
16
+ * Smart-organize flow entered from the selection toolbar. Widens the selection
17
+ * to similar mail once (POST /organize/preview), then lets the user commit the
18
+ * organize sentence at one of four scopes (RFC 034). The widen is the only
19
+ * corpus-wide query; everything after acts on that result.
20
+ */
21
+ export function OrganizeDialog({
22
+ open,
23
+ accountId,
24
+ mailboxId,
25
+ selectedMessageIds,
26
+ onClose,
27
+ }: OrganizeDialogProps) {
28
+ const anchorMessageId = selectedMessageIds[0];
29
+ const { preview, reset, matchedCount, isPending, isError, error } =
30
+ useOrganizePreview(accountId);
31
+
32
+ useEffect(() => {
33
+ if (!open || !anchorMessageId) return;
34
+ preview({
35
+ anchorMessageId,
36
+ matchOperator: "And",
37
+ literalClauses: [],
38
+ });
39
+ }, [open, anchorMessageId, preview]);
40
+
41
+ const handleClose = () => {
42
+ reset();
43
+ onClose();
44
+ };
45
+
46
+ if (!open) return null;
47
+
48
+ return (
49
+ <Dialog open={open} onClose={handleClose} title="Organize similar mail">
50
+ {isPending || matchedCount === undefined ? (
51
+ isError ? (
52
+ <div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
53
+ <p className="text-sm font-medium text-danger">
54
+ Couldn't find similar messages
55
+ </p>
56
+ <p className="max-w-xs text-xs text-fg-muted">
57
+ {error instanceof Error ? error.message : "Please try again."}
58
+ </p>
59
+ <Button variant="ghost" onClick={handleClose} className="mt-2">
60
+ Close
61
+ </Button>
62
+ </div>
63
+ ) : (
64
+ <div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
65
+ <Loader2 className="size-8 animate-spin text-accent-2" />
66
+ <p className="text-sm font-medium text-fg">
67
+ Finding similar messages…
68
+ </p>
69
+ </div>
70
+ )
71
+ ) : (
72
+ <OrganizePanel
73
+ accountId={accountId}
74
+ mailboxId={mailboxId}
75
+ selectedMessageIds={selectedMessageIds}
76
+ anchorMessageId={anchorMessageId}
77
+ matchedCount={matchedCount}
78
+ onClose={handleClose}
79
+ />
80
+ )}
81
+ </Dialog>
82
+ );
83
+ }