@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,267 @@
1
+ import { messageOperationsDescribeMessageOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
3
+ import {
4
+ Avatar,
5
+ ComfortableRowTextContent,
6
+ CompactRowBody,
7
+ comfortableRowClass,
8
+ compactRowClass,
9
+ type Density,
10
+ type SenderTrustLevel,
11
+ type ThreadRowData,
12
+ } from "@remit/ui";
13
+ import { useQueryClient } from "@tanstack/react-query";
14
+ import { Link } from "@tanstack/react-router";
15
+ import { Check } from "lucide-react";
16
+ import { type MouseEvent, memo, useCallback } from "react";
17
+ import { useLongPress } from "@/hooks/useLongPress";
18
+ import type { SelectionModifiers } from "@/hooks/useSelection";
19
+ import { toDisplayCategory } from "@/lib/display-category";
20
+ import { formatEmailDate } from "@/lib/format";
21
+ import { cn } from "@/lib/utils";
22
+ import { AutoMovedIndicator } from "./AutoMovedIndicator";
23
+
24
+ interface MailboxLinkSearch {
25
+ selectedMessageId?: string;
26
+ q?: string;
27
+ }
28
+
29
+ interface MessageListItemProps {
30
+ thread: RemitImapThreadMessageResponse;
31
+ mailboxId: string;
32
+ /** Owning account, used to resolve the Inbox/Junk mailboxes for the auto-moved badge's undo action. */
33
+ accountId?: string;
34
+ isSelected: boolean;
35
+ /**
36
+ * Roving keyboard focus cursor (#429). Distinct from `isSelected` (the open
37
+ * thread): a focused-but-not-open row shows the left accent rail; the open
38
+ * row shows the full highlight. Both can be true (the open row stays focused).
39
+ */
40
+ isFocused?: boolean;
41
+ isChecked: boolean;
42
+ onToggleCheck: (id: string) => void;
43
+ /**
44
+ * Desktop mouse selection. Called from the row's onClick with the click
45
+ * modifiers. Returns true when selection consumed the click (the row should
46
+ * not navigate); false for a plain click (navigation proceeds).
47
+ */
48
+ onRowSelect: (messageId: string, modifiers: SelectionModifiers) => boolean;
49
+ messageCount?: number;
50
+ /** When true, the checkbox is always visible (e.g. mobile multi-select mode). */
51
+ isMultiSelectMode?: boolean;
52
+ /** Called on long press (mobile only). Receives the row's messageId. */
53
+ onLongPress?: (messageId: string) => void;
54
+ /** Whether the current viewport is desktop size. */
55
+ isDesktop?: boolean;
56
+ /** Row density — comfortable (default) or compact (mutt mode). */
57
+ density?: Density;
58
+ }
59
+
60
+ /**
61
+ * Map a RemitImapThreadMessageResponse to the ThreadRowData shape used by
62
+ * remit-ui row body components.
63
+ */
64
+ const toThreadRowData = (
65
+ thread: RemitImapThreadMessageResponse,
66
+ messageCount: number | undefined,
67
+ ): ThreadRowData => {
68
+ // Use the backend's authoritative DKIM-alignment verdict rather than
69
+ // re-deriving it in the view: dkimMismatch already accounts for the
70
+ // multi-signature / alignment semantics a single string compare misses.
71
+ const suspicious = thread.authenticity?.dkimMismatch === true;
72
+
73
+ return {
74
+ id: thread.messageId,
75
+ accountId: thread.accountConfigId,
76
+ fromName: thread.fromName ?? thread.fromEmail ?? "Unknown",
77
+ fromEmail: thread.fromEmail ?? "",
78
+ subject: thread.subject ?? "(No subject)",
79
+ snippet: thread.snippet ?? "",
80
+ timeLabel: formatEmailDate(thread.sentDate),
81
+ isRead: thread.isRead,
82
+ hasAttachment: thread.hasAttachment,
83
+ starred:
84
+ thread.star != null && thread.star !== "none" && thread.hasStars === true,
85
+ trust: thread.senderTrust as SenderTrustLevel,
86
+ category: toDisplayCategory(thread.category),
87
+ messageCount,
88
+ suspicious,
89
+ };
90
+ };
91
+
92
+ const MessageListItemComponent = ({
93
+ thread,
94
+ mailboxId,
95
+ accountId,
96
+ isSelected,
97
+ isFocused = false,
98
+ isChecked,
99
+ onToggleCheck,
100
+ onRowSelect,
101
+ messageCount,
102
+ isMultiSelectMode = false,
103
+ onLongPress,
104
+ isDesktop = true,
105
+ density = "comfortable",
106
+ }: MessageListItemProps) => {
107
+ const queryClient = useQueryClient();
108
+ const messageId = thread.messageId;
109
+ const rowData = toThreadRowData(thread, messageCount);
110
+
111
+ const handleCheckboxClick = (e: MouseEvent) => {
112
+ e.preventDefault();
113
+ e.stopPropagation();
114
+ onToggleCheck(thread.messageId);
115
+ };
116
+
117
+ // Desktop mouse selection semantics. Plain click falls through to the Link's
118
+ // navigation; shift / cmd / ctrl click is routed to selection and the
119
+ // navigation is suppressed. On mobile this is a no-op so taps still open.
120
+ const handleRowClick = useCallback(
121
+ (e: MouseEvent) => {
122
+ if (!isDesktop) return;
123
+ const handled = onRowSelect(messageId, {
124
+ shiftKey: e.shiftKey,
125
+ metaKey: e.metaKey,
126
+ ctrlKey: e.ctrlKey,
127
+ });
128
+ if (handled) {
129
+ e.preventDefault();
130
+ e.stopPropagation();
131
+ }
132
+ },
133
+ [isDesktop, onRowSelect, messageId],
134
+ );
135
+
136
+ const handleLongPress = useCallback(() => {
137
+ onLongPress?.(messageId);
138
+ }, [onLongPress, messageId]);
139
+
140
+ const longPressHandlers = useLongPress({
141
+ onLongPress: handleLongPress,
142
+ delayMs: 500,
143
+ });
144
+
145
+ // Intent-based prefetch: by the time the user clicks, the body is in
146
+ // React Query's cache and the detail pane renders without a spinner.
147
+ const prefetchMessage = useCallback(() => {
148
+ queryClient.prefetchQuery(
149
+ messageOperationsDescribeMessageOptions({
150
+ path: { messageId: thread.messageId },
151
+ }),
152
+ );
153
+ }, [queryClient, thread.messageId]);
154
+
155
+ const unread = !thread.isRead;
156
+
157
+ if (density === "compact") {
158
+ return (
159
+ <Link
160
+ to="/mail/$mailboxId"
161
+ params={{ mailboxId }}
162
+ search={(prev: MailboxLinkSearch) => ({
163
+ ...prev,
164
+ selectedMessageId: thread.messageId,
165
+ })}
166
+ data-message-row
167
+ onClick={handleRowClick}
168
+ onMouseEnter={prefetchMessage}
169
+ onFocus={prefetchMessage}
170
+ {...(!isDesktop && longPressHandlers.handlers)}
171
+ className={cn(
172
+ compactRowClass({ active: isSelected, focused: isFocused }),
173
+ isChecked && "bg-accent-soft",
174
+ !isDesktop && "min-h-11",
175
+ )}
176
+ >
177
+ <CompactRowBody thread={rowData} />
178
+ </Link>
179
+ );
180
+ }
181
+
182
+ // Comfortable density: avatar/checkbox leading slot + text content.
183
+ // The slot is a fixed 36px so the row never reflows when state changes.
184
+ // Unread dot is positioned absolute (left-1.5, vertically centered).
185
+ return (
186
+ <Link
187
+ to="/mail/$mailboxId"
188
+ params={{ mailboxId }}
189
+ search={(prev: MailboxLinkSearch) => ({
190
+ ...prev,
191
+ selectedMessageId: thread.messageId,
192
+ })}
193
+ data-message-row
194
+ onClick={handleRowClick}
195
+ onMouseEnter={prefetchMessage}
196
+ onFocus={prefetchMessage}
197
+ {...(!isDesktop && longPressHandlers.handlers)}
198
+ className={cn(
199
+ "group",
200
+ comfortableRowClass({ active: isSelected, focused: isFocused }),
201
+ isChecked && "bg-accent-soft",
202
+ !isDesktop && "min-h-11",
203
+ )}
204
+ >
205
+ {/* Absolute unread dot — 6px gutter from the left pane hairline */}
206
+ {unread && (
207
+ <span className="absolute left-1.5 top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-accent" />
208
+ )}
209
+
210
+ {/* Leading slot: avatar by default, checkbox on hover (desktop) or
211
+ while checked / in multi-select mode. Fixed 28px slot (size-7,
212
+ matching Avatar size="sm" in remit-ui ComfortableRow). */}
213
+ <div className="relative size-7 shrink-0">
214
+ <Avatar
215
+ name={thread.fromName ?? thread.fromEmail ?? "?"}
216
+ email={thread.fromEmail ?? undefined}
217
+ size="sm"
218
+ className={cn(
219
+ "absolute inset-0",
220
+ "sm:group-hover:opacity-0 transition-opacity",
221
+ (isChecked || isMultiSelectMode) && "opacity-0",
222
+ )}
223
+ />
224
+ <button
225
+ type="button"
226
+ onClick={handleCheckboxClick}
227
+ className={cn(
228
+ "absolute inset-0 size-7 rounded-full border items-center justify-center transition-opacity",
229
+ isMultiSelectMode ? "flex" : "hidden sm:flex",
230
+ isChecked
231
+ ? "bg-accent border-accent text-accent-fg opacity-100"
232
+ : isMultiSelectMode
233
+ ? "border-fg-subtle/40 opacity-100 bg-canvas"
234
+ : "border-fg-subtle/40 opacity-0 group-hover:opacity-100 bg-canvas",
235
+ )}
236
+ aria-label={isChecked ? "Deselect message" : "Select message"}
237
+ >
238
+ {isChecked && <Check className="size-3" />}
239
+ </button>
240
+ </div>
241
+
242
+ {/* Text/glyph content block */}
243
+ <ComfortableRowTextContent
244
+ thread={rowData}
245
+ badge={
246
+ thread.autoMoved && (
247
+ <AutoMovedIndicator
248
+ accountId={accountId}
249
+ messageId={thread.messageId}
250
+ threadId={thread.threadId}
251
+ mailboxId={thread.mailboxId}
252
+ autoMoved={thread.autoMoved}
253
+ size="sm"
254
+ />
255
+ )
256
+ }
257
+ />
258
+ </Link>
259
+ );
260
+ };
261
+
262
+ // Wrapped in React.memo so virtualized rows don't re-render on every parent
263
+ // state change. The `search` callback prop on Link is inline, but it gets a
264
+ // stable reference because the parent itself is stable across re-renders
265
+ // (mailboxId is a string from route params). React.memo with default shallow
266
+ // equality is appropriate here since props are primitives + stable callback.
267
+ export const MessageListItem = memo(MessageListItemComponent);
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Render invariants for the reading-pane toolbar (#799): the never-disable
3
+ * tenet (`doc/rules/ux.md`). The toolbar's action buttons must stay pressable
4
+ * even with no thread open — they no-op and explain rather than greying out.
5
+ *
6
+ * Asserted against the shared `MailActionToolbar` (remit-ui), which the live
7
+ * `MessageToolbar` composes. Uses `react-dom/server` so no jsdom is needed.
8
+ */
9
+
10
+ import assert from "node:assert/strict";
11
+ import { describe, it } from "node:test";
12
+ import { MailActionToolbar } from "@remit/ui";
13
+ import React, { createElement } from "react";
14
+ import { renderToString } from "react-dom/server";
15
+
16
+ // The node test loader transpiles remit-ui's `.tsx` (resolved through
17
+ // node_modules) with the classic JSX runtime, which references a global
18
+ // `React`. Vite/Storybook use the automatic runtime, so this shim only exists
19
+ // for the SSR test harness.
20
+ (globalThis as { React?: typeof React }).React = React;
21
+
22
+ const render = (props: Parameters<typeof MailActionToolbar>[0]): string =>
23
+ renderToString(createElement(MailActionToolbar, props) as never);
24
+
25
+ describe("MailActionToolbar never disables its action buttons (#799)", () => {
26
+ it("renders no disabled button with no thread open", () => {
27
+ const html = render({ hasThread: false });
28
+ assert.equal(
29
+ /\sdisabled(=""|\s|>)/.test(html),
30
+ false,
31
+ "no toolbar button may carry a `disabled` attribute",
32
+ );
33
+ });
34
+
35
+ it("renders no disabled button with a thread open", () => {
36
+ const html = render({ hasThread: true });
37
+ assert.equal(/\sdisabled(=""|\s|>)/.test(html), false);
38
+ });
39
+
40
+ it("renders the inline explanation when supplied with no thread", () => {
41
+ const html = render({
42
+ hasThread: false,
43
+ unavailableHint: "Open a message first",
44
+ });
45
+ assert.match(html, /Open a message first/);
46
+ // Inline status, not a toast.
47
+ assert.match(html, /role="status"/);
48
+ });
49
+
50
+ it("keeps the action buttons present (reply/flag) so they stay pressable", () => {
51
+ const html = render({ hasThread: false });
52
+ assert.match(html, /aria-label="Reply"/);
53
+ assert.match(html, /aria-label="Flag"/);
54
+ // No archive verb — Remit is IMAP-backed (move-to-folder is the equivalent).
55
+ assert.doesNotMatch(html, /aria-label="Archive"/);
56
+ });
57
+ });
@@ -0,0 +1,166 @@
1
+ import {
2
+ Button,
3
+ type MailAction,
4
+ MailActionToolbar,
5
+ SearchBar,
6
+ } from "@remit/ui";
7
+ import { Info, SquarePen } from "lucide-react";
8
+ import { useState } from "react";
9
+ import { AccountMenu } from "@/auth/AccountMenu";
10
+ import { BugReportButton } from "@/components/ui/BugReportButton";
11
+ import { tooltipForAction } from "@/lib/keymap";
12
+ import { MoveToTrigger } from "./MoveToTrigger";
13
+
14
+ /**
15
+ * Message action toolbar on the pane-header datum (40px, the shared
16
+ * `--spacing-pane-header`). The reading pane's verbs — reply / reply-all /
17
+ * forward, then delete / move / flag — Apple-Mail-style ghost icon buttons,
18
+ * then the search field top-right (still filters the current list), compose
19
+ * (✎) and the intelligence toggle (#422).
20
+ *
21
+ * Buttons are always pressable (never `disabled`): with no thread open a press
22
+ * is a no-op that surfaces a one-line inline explanation rather than greying
23
+ * out (`doc/rules/ux.md`).
24
+ */
25
+ export interface MessageToolbarProps {
26
+ hasThread: boolean;
27
+ onCompose: () => void;
28
+ intelligenceOpen: boolean;
29
+ /**
30
+ * Whether the intelligence toggle is shown at all. The rail is contextual
31
+ * to an open message, so the toggle only appears once a thread is selected
32
+ * (matches the remit-ui AppShell reference) — it never opens an empty rail.
33
+ */
34
+ showIntelligenceToggle: boolean;
35
+ onToggleIntelligence: () => void;
36
+ searchValue: string;
37
+ onSearchChange: (value: string) => void;
38
+ /** Full clear (X button): drops the query and any selected thread (#538). */
39
+ onSearchClear: () => void;
40
+ /** Query-only clear (Esc): drops the query, keeps the thread open (#489). */
41
+ onSearchClearQuery?: () => void;
42
+
43
+ /* ---- wired action callbacks (omit to keep the no-op-explain behaviour) ---- */
44
+ onReply?: () => void;
45
+ onReplyAll?: () => void;
46
+ onForward?: () => void;
47
+ /** Delete all messages in the open thread. */
48
+ onDelete?: () => void;
49
+ /**
50
+ * Override the enabled state of the delete button. Defaults to `hasThread`.
51
+ * Pass `true` when a non-thread item (e.g. a Remit draft) is active so the
52
+ * trash icon acts without acting on the other thread-scoped verbs.
53
+ */
54
+ canDelete?: boolean;
55
+ /** Toggle the star on the most-recent message in the thread. */
56
+ onToggleStar?: () => void;
57
+ /** Whether the most-recent message is starred. */
58
+ isStarred?: boolean;
59
+ /**
60
+ * Move-to-mailbox trigger context. When present the FolderInput button
61
+ * is replaced by the full `MoveToTrigger` popover.
62
+ */
63
+ moveContext?: {
64
+ accountId: string;
65
+ currentMailboxId: string;
66
+ onMove: (destinationMailboxId: string) => void;
67
+ };
68
+ }
69
+
70
+ const OPEN_FIRST = "Open a message first";
71
+
72
+ export const MessageToolbar = ({
73
+ hasThread,
74
+ onCompose,
75
+ intelligenceOpen,
76
+ showIntelligenceToggle,
77
+ onToggleIntelligence,
78
+ searchValue,
79
+ onSearchChange,
80
+ onSearchClear,
81
+ onSearchClearQuery,
82
+ onReply,
83
+ onReplyAll,
84
+ onForward,
85
+ onDelete,
86
+ canDelete,
87
+ onToggleStar,
88
+ isStarred,
89
+ moveContext,
90
+ }: MessageToolbarProps) => {
91
+ const [hint, setHint] = useState<string | null>(null);
92
+ const canDeleteResolved = canDelete ?? hasThread;
93
+ const explain = (message: string) => () => setHint(message);
94
+
95
+ return (
96
+ <MailActionToolbar
97
+ hasThread={hasThread}
98
+ isStarred={isStarred}
99
+ onUnavailable={(_action: MailAction) => setHint(OPEN_FIRST)}
100
+ unavailableHint={hint}
101
+ replyTitle={`Reply ${tooltipForAction("reply")}`}
102
+ replyAllTitle={`Reply all ${tooltipForAction("replyAll")}`}
103
+ forwardTitle={`Forward ${tooltipForAction("forward")}`}
104
+ deleteTitle={`Move to Trash ${tooltipForAction("delete")}`}
105
+ flagTitle={`Flag ${tooltipForAction("toggleStar")}`}
106
+ onReply={onReply}
107
+ onReplyAll={onReplyAll}
108
+ onForward={onForward}
109
+ onDelete={canDeleteResolved ? onDelete : explain(OPEN_FIRST)}
110
+ onToggleStar={onToggleStar}
111
+ onMove={explain(OPEN_FIRST)}
112
+ moveSlot={
113
+ moveContext ? (
114
+ <MoveToTrigger
115
+ accountId={moveContext.accountId}
116
+ currentMailboxId={moveContext.currentMailboxId}
117
+ onMove={moveContext.onMove}
118
+ label="Move to mailbox"
119
+ />
120
+ ) : undefined
121
+ }
122
+ >
123
+ {/* Apple Mail geometry: search sits top-right over the message area
124
+ but still filters the current list. Reuses the wired SearchBar
125
+ (the global "/" focus shortcut + Escape handling live in it). */}
126
+ <div className="w-64 min-w-40 shrink">
127
+ <SearchBar
128
+ value={searchValue}
129
+ onChange={onSearchChange}
130
+ onClear={onSearchClear}
131
+ onClearQuery={onSearchClearQuery}
132
+ placeholder="Search mail"
133
+ />
134
+ </div>
135
+ <span className="mx-1 h-4 w-px bg-line" aria-hidden />
136
+ <Button
137
+ variant="ghost"
138
+ size="sm"
139
+ icon={<SquarePen className="size-4" />}
140
+ title={`Compose ${tooltipForAction("compose")}`}
141
+ aria-label="Compose"
142
+ onClick={onCompose}
143
+ />
144
+ {showIntelligenceToggle && (
145
+ <Button
146
+ variant="ghost"
147
+ size="sm"
148
+ icon={<Info className="size-4" />}
149
+ title="Intelligence"
150
+ aria-label={
151
+ intelligenceOpen
152
+ ? "Hide intelligence sidebar"
153
+ : "Show intelligence sidebar"
154
+ }
155
+ aria-pressed={intelligenceOpen}
156
+ onClick={onToggleIntelligence}
157
+ className={
158
+ intelligenceOpen ? "bg-accent-2-soft text-accent-2" : undefined
159
+ }
160
+ />
161
+ )}
162
+ <BugReportButton />
163
+ <AccountMenu />
164
+ </MailActionToolbar>
165
+ );
166
+ };
@@ -0,0 +1,142 @@
1
+ import {
2
+ messageBulkOperationsUpdateFlagsMutation,
3
+ threadDetailOperationsListThreadMessagesQueryKey,
4
+ threadOperationsListThreadsQueryKey,
5
+ threadOperationsSearchThreadsQueryKey,
6
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
7
+ import { MobileMessageActionBar, type PopoverMenuItem } from "@remit/ui";
8
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
9
+ import { BadgeCheck, Check, Code } from "lucide-react";
10
+ import { useCallback } from "react";
11
+ import { useDeleteMessages } from "@/hooks/useDeleteMessages";
12
+ import { useMoveMessages } from "@/hooks/useMoveMessages";
13
+ import { useToggleTrusted } from "@/hooks/useToggleTrusted";
14
+ import { MoveToTrigger } from "./MoveToTrigger";
15
+
16
+ interface MobileMessageBarProps {
17
+ messageId: string;
18
+ threadId: string;
19
+ mailboxId: string;
20
+ accountId?: string;
21
+ isRead: boolean;
22
+ isStarred: boolean;
23
+ onToggleStar: () => void;
24
+ /** From-address id; enables the "Trusted sender" overflow item. */
25
+ fromAddressId?: string;
26
+ isTrusted: boolean;
27
+ showRaw: boolean;
28
+ onToggleRaw?: () => void;
29
+ onReply?: () => void;
30
+ onReplyAll?: () => void;
31
+ onForward?: () => void;
32
+ }
33
+
34
+ /**
35
+ * The live per-message action bar for the mobile single-pane reading view.
36
+ * Wraps the kit `MobileMessageActionBar` and owns this message's mutations:
37
+ * star (host), move-to-folder, delete, mark read/unread, plus the trusted /
38
+ * raw toggles in the overflow. Reply verbs come from the conversation.
39
+ */
40
+ export const MobileMessageBar = ({
41
+ messageId,
42
+ threadId,
43
+ mailboxId,
44
+ accountId,
45
+ isRead,
46
+ isStarred,
47
+ onToggleStar,
48
+ fromAddressId,
49
+ isTrusted,
50
+ showRaw,
51
+ onToggleRaw,
52
+ onReply,
53
+ onReplyAll,
54
+ onForward,
55
+ }: MobileMessageBarProps) => {
56
+ const queryClient = useQueryClient();
57
+
58
+ const { deleteMessages } = useDeleteMessages({ mailboxId, threadId });
59
+ const { moveMessages } = useMoveMessages({ mailboxId, threadId, accountId });
60
+ const { toggleTrusted } = useToggleTrusted({ messageId });
61
+
62
+ const { mutate: updateFlags } = useMutation({
63
+ ...messageBulkOperationsUpdateFlagsMutation(),
64
+ onSettled: () => {
65
+ queryClient.invalidateQueries({
66
+ queryKey: threadDetailOperationsListThreadMessagesQueryKey({
67
+ path: { threadId },
68
+ }),
69
+ });
70
+ queryClient.invalidateQueries({
71
+ queryKey: threadOperationsListThreadsQueryKey({ path: { mailboxId } }),
72
+ });
73
+ queryClient.invalidateQueries({
74
+ queryKey: threadOperationsSearchThreadsQueryKey({
75
+ path: { mailboxId },
76
+ }),
77
+ });
78
+ },
79
+ });
80
+
81
+ const handleMove = useCallback(
82
+ (destinationMailboxId: string) => {
83
+ moveMessages([messageId], destinationMailboxId);
84
+ },
85
+ [moveMessages, messageId],
86
+ );
87
+
88
+ const handleDelete = useCallback(() => {
89
+ deleteMessages([messageId]);
90
+ }, [deleteMessages, messageId]);
91
+
92
+ const handleToggleRead = useCallback(() => {
93
+ updateFlags({ body: { messageIds: [messageId], isRead: !isRead } });
94
+ }, [updateFlags, messageId, isRead]);
95
+
96
+ const overflowItems: PopoverMenuItem[] = [];
97
+ if (fromAddressId) {
98
+ overflowItems.push({
99
+ key: "trusted",
100
+ label: isTrusted ? "Trusted sender" : "Mark sender trusted",
101
+ icon: isTrusted ? (
102
+ <BadgeCheck className="size-4 text-positive" />
103
+ ) : (
104
+ <Check className="size-4" />
105
+ ),
106
+ onSelect: () => toggleTrusted(fromAddressId, isTrusted),
107
+ });
108
+ }
109
+ if (onToggleRaw) {
110
+ overflowItems.push({
111
+ key: "raw",
112
+ label: showRaw ? "Show formatted" : "Show raw email",
113
+ icon: <Code className="size-4" />,
114
+ onSelect: onToggleRaw,
115
+ });
116
+ }
117
+
118
+ return (
119
+ <MobileMessageActionBar
120
+ hasThread
121
+ onReply={onReply}
122
+ onReplyAll={onReplyAll}
123
+ onForward={onForward}
124
+ isStarred={isStarred}
125
+ onToggleStar={onToggleStar}
126
+ onDelete={handleDelete}
127
+ isRead={isRead}
128
+ onToggleRead={handleToggleRead}
129
+ overflowItems={overflowItems}
130
+ moveSlot={
131
+ accountId ? (
132
+ <MoveToTrigger
133
+ accountId={accountId}
134
+ currentMailboxId={mailboxId}
135
+ onMove={handleMove}
136
+ label="Move this message"
137
+ />
138
+ ) : undefined
139
+ }
140
+ />
141
+ );
142
+ };