@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,804 @@
1
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
2
+ import {
3
+ type Density,
4
+ MessageListPane,
5
+ SelectionTopBar,
6
+ } from "@remit/ui";
7
+ import { useNavigate } from "@tanstack/react-router";
8
+ import { useVirtualizer } from "@tanstack/react-virtual";
9
+ import { Search, Sparkles } from "lucide-react";
10
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
11
+ import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
12
+ import { formatErrorMessage } from "@/components/ui/ErrorState";
13
+ import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
14
+ import { useToggleReadFor } from "@/hooks/useMarkAsRead";
15
+ import { useIsDesktop } from "@/hooks/useMediaQuery";
16
+ import {
17
+ nextFocusId,
18
+ type SelectionModifiers,
19
+ useSelection,
20
+ } from "@/hooks/useSelection";
21
+ import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
22
+ import { formatDeleteToTrashTitle } from "@/lib/format";
23
+ import { MoveToTrigger } from "./MoveToTrigger";
24
+ import { OrganizeDialog } from "./organize/OrganizeDialog";
25
+ import { SelectionToolbar } from "./SelectionToolbar";
26
+ import { SwipeableMessageRow } from "./SwipeableMessageRow";
27
+
28
+ interface MessageListProps {
29
+ mailboxId: string;
30
+ threads: RemitImapThreadMessageResponse[];
31
+ selectedMessageId?: string;
32
+ isLoading: boolean;
33
+ isError?: boolean;
34
+ error?: unknown;
35
+ onRetry?: () => void;
36
+ searchQuery?: string;
37
+ onDeleteMessages?: (messageIds: string[]) => void;
38
+ onMarkAsRead?: (messageIds: string[]) => void;
39
+ onMoveMessages?: (messageIds: string[], destinationMailboxId: string) => void;
40
+ isDeleting?: boolean;
41
+ isMoving?: boolean;
42
+ onLoadMore?: () => void;
43
+ hasMore?: boolean;
44
+ isLoadingMore?: boolean;
45
+ /**
46
+ * Owning account for the current mailbox view. Required for the Move
47
+ * action — when missing or when the selection spans multiple accounts
48
+ * the toolbar disables Move and surfaces an inline hint.
49
+ */
50
+ accountId?: string;
51
+ /**
52
+ * Mailbox display title shown in the list pane header.
53
+ * The parent route owns the title; the list pane renders it.
54
+ */
55
+ listTitle: string;
56
+ /**
57
+ * Optional subtitle (e.g. "3 unread") shown alongside the title.
58
+ */
59
+ listMeta?: string;
60
+ /**
61
+ * Triage-layer context bridge (#429). The roving focus cursor and the
62
+ * multi-selection live here; the parent route's global keyboard dispatcher
63
+ * needs them to target the action verbs (reply/star/…) at the
64
+ * focused row, or the selection when one exists. Called whenever either
65
+ * changes. `focusedMessageId` is the keyboard cursor (distinct from the
66
+ * open/selected thread in the URL); `selectedIds` is the checkbox set.
67
+ */
68
+ onTriageContextChange?: (context: {
69
+ focusedMessageId: string | undefined;
70
+ selectedIds: string[];
71
+ }) => void;
72
+ /**
73
+ * Suppress the pane's built-in title header — the shared `MailHeader` above
74
+ * the list owns it (the inbox renders inside `MailViewChrome`).
75
+ */
76
+ hideHeader?: boolean;
77
+ }
78
+
79
+ const COMFORTABLE_ITEM_HEIGHT = 72;
80
+ const COMPACT_ITEM_HEIGHT = 32;
81
+ const OVERSCAN_COUNT = 5;
82
+ const DENSITY_STORAGE_KEY = "remit:list-density";
83
+
84
+ const readStoredDensity = (): Density => {
85
+ try {
86
+ const stored = localStorage.getItem(DENSITY_STORAGE_KEY);
87
+ if (stored === "compact" || stored === "comfortable") return stored;
88
+ } catch {
89
+ // localStorage unavailable (SSR, privacy mode) — fall through
90
+ }
91
+ return "comfortable";
92
+ };
93
+
94
+ const SearchResultsHeader = ({
95
+ query,
96
+ count,
97
+ }: {
98
+ query: string;
99
+ count: number;
100
+ }) => (
101
+ <div className="flex items-center gap-2 px-3 py-2 border-b border-line bg-surface-sunken/30">
102
+ <Search className="size-4 text-fg-muted" />
103
+ <span className="text-sm text-fg-muted">
104
+ {count} {count === 1 ? "result" : "results"} for &ldquo;{query}&rdquo;
105
+ </span>
106
+ </div>
107
+ );
108
+
109
+ export const MessageList = ({
110
+ mailboxId,
111
+ threads,
112
+ selectedMessageId,
113
+ isLoading,
114
+ isError = false,
115
+ error,
116
+ onRetry,
117
+ searchQuery,
118
+ onDeleteMessages,
119
+ onMarkAsRead,
120
+ onMoveMessages,
121
+ isDeleting = false,
122
+ isMoving = false,
123
+ onLoadMore,
124
+ hasMore = false,
125
+ isLoadingMore = false,
126
+ accountId,
127
+ listTitle,
128
+ listMeta,
129
+ onTriageContextChange,
130
+ hideHeader = false,
131
+ }: MessageListProps) => {
132
+ const parentRef = useRef<HTMLDivElement>(null);
133
+ const navigate = useNavigate();
134
+ const isDesktop = useIsDesktop();
135
+ const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
136
+ const [organizeOpen, setOrganizeOpen] = useState(false);
137
+
138
+ // Roving focus cursor (#429): the keyboard "where am I" pointer, distinct
139
+ // from the open thread (`selectedMessageId` in the URL). j/k move this
140
+ // cursor without opening; Enter opens the focused row → sets selected. It
141
+ // seeds from the open thread so opening a message also focuses its row, and
142
+ // click-to-open keeps working unchanged (the route still navigates).
143
+ const [focusedMessageId, setFocusedMessageId] = useState<string | undefined>(
144
+ selectedMessageId,
145
+ );
146
+
147
+ // Density toggle: comfortable (default) or compact (mutt mode).
148
+ // Persisted to localStorage so the choice survives reloads.
149
+ const [density, setDensity] = useState<Density>(readStoredDensity);
150
+ const toggleDensity = useCallback(() => {
151
+ setDensity((prev) => {
152
+ const next: Density = prev === "comfortable" ? "compact" : "comfortable";
153
+ try {
154
+ localStorage.setItem(DENSITY_STORAGE_KEY, next);
155
+ } catch {
156
+ // ignore
157
+ }
158
+ return next;
159
+ });
160
+ }, []);
161
+
162
+ // Swipe-to-read toggle hook
163
+ const { toggleReadFor } = useToggleReadFor({ mailboxId });
164
+
165
+ // Selection state
166
+ const {
167
+ selectedIds,
168
+ selectedCount,
169
+ hasSelection,
170
+ isSelected: isChecked,
171
+ toggle: toggleCheck,
172
+ select,
173
+ clearSelection,
174
+ selectRange,
175
+ setAnchor,
176
+ selectAll,
177
+ } = useSelection();
178
+
179
+ // Ids queued for deletion, awaiting confirmation. `null` means the dialog
180
+ // is closed. Snapshotted at request time so a selection change behind the
181
+ // dialog can't retarget the delete.
182
+ const [pendingDeleteIds, setPendingDeleteIds] = useState<string[] | null>(
183
+ null,
184
+ );
185
+
186
+ // Auto-exit multi-select when selection becomes empty
187
+ useEffect(() => {
188
+ if (isMultiSelectMode && selectedCount === 0) {
189
+ setIsMultiSelectMode(false);
190
+ }
191
+ }, [isMultiSelectMode, selectedCount]);
192
+
193
+ const virtualizer = useVirtualizer({
194
+ count: threads.length,
195
+ getScrollElement: () => parentRef.current,
196
+ estimateSize: () =>
197
+ density === "compact" ? COMPACT_ITEM_HEIGHT : COMFORTABLE_ITEM_HEIGHT,
198
+ overscan: OVERSCAN_COUNT,
199
+ });
200
+
201
+ // Index of the open thread (for scroll-into-view of the reading pane target).
202
+ const currentIndex = selectedMessageId
203
+ ? threads.findIndex((t) => t.messageId === selectedMessageId)
204
+ : -1;
205
+
206
+ // Index of the roving focus cursor — what j/k move.
207
+ const focusIndex = focusedMessageId
208
+ ? threads.findIndex((t) => t.messageId === focusedMessageId)
209
+ : -1;
210
+
211
+ // Move the focus cursor by index. In multi-select mode (mobile) j/k still
212
+ // toggle selection rather than moving a cursor, preserving prior behavior.
213
+ const moveFocusToIndex = useCallback(
214
+ (index: number) => {
215
+ if (index < 0 || index >= threads.length) return;
216
+ const thread = threads[index];
217
+ if (isMultiSelectMode) {
218
+ toggleCheck(thread.messageId);
219
+ return;
220
+ }
221
+ setFocusedMessageId(thread.messageId);
222
+ },
223
+ [threads, isMultiSelectMode, toggleCheck],
224
+ );
225
+
226
+ // j / ArrowDown: move focus to the next row (no open). Starts at the top
227
+ // when nothing is focused yet.
228
+ const focusNext = useCallback(() => {
229
+ if (threads.length === 0) return;
230
+ const nextIndex =
231
+ focusIndex < 0 ? 0 : Math.min(focusIndex + 1, threads.length - 1);
232
+ moveFocusToIndex(nextIndex);
233
+ }, [threads.length, focusIndex, moveFocusToIndex]);
234
+
235
+ // k / ArrowUp: move focus to the previous row.
236
+ const focusPrevious = useCallback(() => {
237
+ if (threads.length === 0) return;
238
+ const prevIndex = focusIndex <= 0 ? 0 : focusIndex - 1;
239
+ moveFocusToIndex(prevIndex);
240
+ }, [threads.length, focusIndex, moveFocusToIndex]);
241
+
242
+ // Toggle selection on the focused row with x.
243
+ const toggleFocusedSelection = useCallback(() => {
244
+ if (focusedMessageId) {
245
+ toggleCheck(focusedMessageId);
246
+ }
247
+ }, [focusedMessageId, toggleCheck]);
248
+
249
+ // Desktop mouse selection semantics (Apple Mail / Gmail model). Called by a
250
+ // row's onClick with the click modifiers. Returns true when selection
251
+ // handled the click (caller should preventDefault and skip navigation);
252
+ // false for a plain click (caller lets the Link navigate).
253
+ const orderedIds = useMemo(() => threads.map((t) => t.messageId), [threads]);
254
+ const handleRowSelect = useCallback(
255
+ (messageId: string, modifiers: SelectionModifiers): boolean => {
256
+ if (modifiers.shiftKey) {
257
+ selectRange(orderedIds, messageId);
258
+ return true;
259
+ }
260
+ if (modifiers.metaKey || modifiers.ctrlKey) {
261
+ // Toggle membership and re-anchor on the clicked row.
262
+ toggleCheck(messageId);
263
+ return true;
264
+ }
265
+ // Plain click: collapse any multi-selection and let navigation proceed.
266
+ // The clicked row becomes the next anchor for a subsequent shift-click,
267
+ // but is NOT added to the checkbox set (no toolbar on a plain open).
268
+ clearSelection();
269
+ setAnchor(messageId);
270
+ return false;
271
+ },
272
+ [orderedIds, selectRange, toggleCheck, clearSelection, setAnchor],
273
+ );
274
+
275
+ // Open the delete confirmation for an explicit set of ids. All delete
276
+ // entry points (toolbar Trash2, Delete/Backspace key) funnel through here
277
+ // so the move-to-Trash confirmation is consistent.
278
+ const requestDelete = useCallback(
279
+ (ids: string[]) => {
280
+ if (!onDeleteMessages || ids.length === 0) return;
281
+ setPendingDeleteIds(ids);
282
+ },
283
+ [onDeleteMessages],
284
+ );
285
+
286
+ // Keyboard shift-arrow range extend: move focus one row in `direction` and
287
+ // extend the selection range from the existing anchor to the new focus —
288
+ // the keyboard equivalent of shift-click. The anchor stays fixed across
289
+ // consecutive shift-arrows (selectRange seeds it only when unset), so
290
+ // moving back toward the anchor shrinks the range.
291
+ const extendRange = useCallback(
292
+ (direction: -1 | 1) => {
293
+ const target = nextFocusId(orderedIds, focusedMessageId, direction);
294
+ if (target === undefined) return;
295
+ selectRange(orderedIds, target);
296
+ // Shift+arrow moves the focus cursor (not the open thread) and grows
297
+ // the selection from the anchor — the keyboard equivalent of
298
+ // shift-click.
299
+ setFocusedMessageId(target);
300
+ },
301
+ [orderedIds, focusedMessageId, selectRange],
302
+ );
303
+
304
+ const extendRangeUp = useCallback(() => extendRange(-1), [extendRange]);
305
+ const extendRangeDown = useCallback(() => extendRange(1), [extendRange]);
306
+
307
+ // Cmd/Ctrl+A: select every currently loaded row.
308
+ const handleSelectAll = useCallback(() => {
309
+ if (orderedIds.length > 0) {
310
+ selectAll(orderedIds);
311
+ }
312
+ }, [orderedIds, selectAll]);
313
+
314
+ // Delete / Backspace: confirm-delete the selection, or the focused row when
315
+ // nothing is selected.
316
+ const handleDeleteKey = useCallback(() => {
317
+ if (selectedCount > 0) {
318
+ requestDelete(Array.from(selectedIds));
319
+ } else if (focusedMessageId) {
320
+ requestDelete([focusedMessageId]);
321
+ }
322
+ }, [selectedCount, selectedIds, focusedMessageId, requestDelete]);
323
+
324
+ // Enter: open the focused row in the reading pane (sets selected/URL). This
325
+ // is the focus→open transition of the 2-state model.
326
+ const handleOpenFocused = useCallback(() => {
327
+ if (!focusedMessageId) return;
328
+ navigate({
329
+ to: "/mail/$mailboxId",
330
+ params: { mailboxId },
331
+ search: (prev) => ({ ...prev, selectedMessageId: focusedMessageId }),
332
+ });
333
+ }, [focusedMessageId, navigate, mailboxId]);
334
+
335
+ // Toolbar Trash2: confirm-delete the current selection.
336
+ const handleDelete = useCallback(() => {
337
+ if (selectedCount > 0) {
338
+ requestDelete(Array.from(selectedIds));
339
+ }
340
+ }, [requestDelete, selectedCount, selectedIds]);
341
+
342
+ // Confirm handler: run the actual bulk delete, then clear selection and
343
+ // move focus to a sensible neighbor (the row after the first deleted one).
344
+ const handleConfirmDelete = useCallback(() => {
345
+ if (!pendingDeleteIds || pendingDeleteIds.length === 0) {
346
+ setPendingDeleteIds(null);
347
+ return;
348
+ }
349
+ const deletedSet = new Set(pendingDeleteIds);
350
+ const firstDeletedIndex = threads.findIndex((t) =>
351
+ deletedSet.has(t.messageId),
352
+ );
353
+ // Next surviving row at or after the first deleted row, else the one
354
+ // before it. Computed against the pre-delete order.
355
+ let nextFocus: string | undefined;
356
+ for (let i = firstDeletedIndex + 1; i < threads.length; i++) {
357
+ if (!deletedSet.has(threads[i].messageId)) {
358
+ nextFocus = threads[i].messageId;
359
+ break;
360
+ }
361
+ }
362
+ if (nextFocus === undefined) {
363
+ for (let i = firstDeletedIndex - 1; i >= 0; i--) {
364
+ if (!deletedSet.has(threads[i].messageId)) {
365
+ nextFocus = threads[i].messageId;
366
+ break;
367
+ }
368
+ }
369
+ }
370
+
371
+ onDeleteMessages?.(pendingDeleteIds);
372
+ clearSelection();
373
+ setPendingDeleteIds(null);
374
+
375
+ if (nextFocus !== undefined) {
376
+ navigate({
377
+ to: "/mail/$mailboxId",
378
+ params: { mailboxId },
379
+ search: (prev) => ({ ...prev, selectedMessageId: nextFocus }),
380
+ replace: true,
381
+ });
382
+ }
383
+ }, [
384
+ pendingDeleteIds,
385
+ threads,
386
+ onDeleteMessages,
387
+ clearSelection,
388
+ navigate,
389
+ mailboxId,
390
+ ]);
391
+
392
+ const handleCancelDelete = useCallback(() => {
393
+ setPendingDeleteIds(null);
394
+ }, []);
395
+
396
+ // Handle mark as read
397
+ const handleMarkAsRead = useCallback(() => {
398
+ if (onMarkAsRead && selectedCount > 0) {
399
+ onMarkAsRead(Array.from(selectedIds));
400
+ }
401
+ }, [onMarkAsRead, selectedCount, selectedIds]);
402
+
403
+ // Handle move
404
+ const handleMoveSelected = useCallback(
405
+ (destinationMailboxId: string) => {
406
+ if (!onMoveMessages || selectedCount === 0) return;
407
+ onMoveMessages(Array.from(selectedIds), destinationMailboxId);
408
+ clearSelection();
409
+ },
410
+ [onMoveMessages, selectedCount, selectedIds, clearSelection],
411
+ );
412
+
413
+ // Cross-account guard: every selected thread row must belong to the
414
+ // same account as the current mailbox. The list is already scoped to
415
+ // one mailbox so in practice this is always single-account, but we
416
+ // detect drift defensively (e.g. a future global selection mode) and
417
+ // disable Move with an inline hint rather than silently aggregating.
418
+ //
419
+ // `MessageList` re-renders on every virtualizer scroll tick. Memoize
420
+ // the guard so we only walk the selected slice when selection or
421
+ // thread identity actually changes.
422
+ const moveDisabledHint = useMemo(() => {
423
+ if (selectedCount === 0) return undefined;
424
+ const selectedAccountConfigIds = new Set<string>();
425
+ for (const thread of threads) {
426
+ if (selectedIds.has(thread.messageId)) {
427
+ selectedAccountConfigIds.add(thread.accountConfigId);
428
+ }
429
+ }
430
+ if (selectedAccountConfigIds.size > 1) {
431
+ return "Move only works within one account — clear selection or pick messages from a single account";
432
+ }
433
+ return undefined;
434
+ }, [selectedCount, selectedIds, threads]);
435
+
436
+ // Swipe-to-delete single message
437
+ const handleSwipeDelete = useCallback(
438
+ (messageId: string) => {
439
+ onDeleteMessages?.([messageId]);
440
+ },
441
+ [onDeleteMessages],
442
+ );
443
+
444
+ // Swipe-to-toggle-read single message
445
+ const handleSwipeToggleRead = useCallback(
446
+ (messageId: string, currentIsRead: boolean) => {
447
+ toggleReadFor([messageId], !currentIsRead);
448
+ },
449
+ [toggleReadFor],
450
+ );
451
+
452
+ // Mobile: Enter multi-select mode on long press
453
+ const handleLongPress = useCallback(
454
+ (messageId: string) => {
455
+ if (!isDesktop) {
456
+ setIsMultiSelectMode(true);
457
+ select(messageId);
458
+ }
459
+ },
460
+ [isDesktop, select],
461
+ );
462
+
463
+ // Cancel multi-select mode
464
+ const handleCancelMultiSelect = useCallback(() => {
465
+ setIsMultiSelectMode(false);
466
+ clearSelection();
467
+ }, [clearSelection]);
468
+
469
+ // Scroll the roving focus cursor into view as it moves (j/k). Falls back to
470
+ // the open thread when nothing is focused yet.
471
+ useEffect(() => {
472
+ // On single-pane tiers, opening a thread swaps this list out for the
473
+ // conversation. Scrolling the list as it unmounts is both pointless (it's
474
+ // no longer visible) and unsafe: @tanstack/react-virtual's scrollToIndex
475
+ // schedules a requestAnimationFrame retry chain on the scroll element's
476
+ // window, which throws once that element (and its window) are gone. Only
477
+ // auto-scroll while the list stays mounted alongside the reading pane.
478
+ if (!isDesktop && selectedMessageId) return;
479
+ const target = focusIndex >= 0 ? focusIndex : currentIndex;
480
+ if (target >= 0) {
481
+ virtualizer.scrollToIndex(target, { align: "auto" });
482
+ }
483
+ }, [focusIndex, currentIndex, virtualizer, isDesktop, selectedMessageId]);
484
+
485
+ // Opening a thread (click or Enter, anywhere) seeds the focus cursor onto it
486
+ // so subsequent j/k continue from the open row — focus and open stay in
487
+ // sync on open while remaining independent during scanning.
488
+ useEffect(() => {
489
+ if (selectedMessageId) {
490
+ setFocusedMessageId(selectedMessageId);
491
+ }
492
+ }, [selectedMessageId]);
493
+
494
+ // Keep the focus cursor valid as the thread list changes (after delete /
495
+ // move / refetch). If the focused row vanished, snap to the nearest
496
+ // surviving row so j/k never dead-ends.
497
+ useEffect(() => {
498
+ if (!focusedMessageId) return;
499
+ if (threads.some((t) => t.messageId === focusedMessageId)) return;
500
+ setFocusedMessageId(threads[0]?.messageId);
501
+ }, [threads, focusedMessageId]);
502
+
503
+ // Bridge the roving cursor + selection up to the route's global keyboard
504
+ // dispatcher (#429) so the action verbs can target the focused row, or the
505
+ // selection when one exists.
506
+ useEffect(() => {
507
+ onTriageContextChange?.({
508
+ focusedMessageId,
509
+ selectedIds: Array.from(selectedIds),
510
+ });
511
+ }, [focusedMessageId, selectedIds, onTriageContextChange]);
512
+
513
+ // Clear selection when threads change (e.g., after delete)
514
+ useEffect(() => {
515
+ const threadIds = new Set(threads.map((t) => t.messageId));
516
+ const hasOrphanedSelection = Array.from(selectedIds).some(
517
+ (id) => !threadIds.has(id),
518
+ );
519
+ if (hasOrphanedSelection) {
520
+ clearSelection();
521
+ }
522
+ }, [threads, selectedIds, clearSelection]);
523
+
524
+ // Load more when scrolling near the bottom
525
+ useEffect(() => {
526
+ const scrollElement = parentRef.current;
527
+ if (!scrollElement || !hasMore || !onLoadMore) return;
528
+
529
+ const handleScroll = () => {
530
+ if (isLoadingMore) return;
531
+
532
+ const { scrollTop, scrollHeight, clientHeight } = scrollElement;
533
+ // Trigger when within 200px of the bottom
534
+ const nearBottom = scrollTop + clientHeight >= scrollHeight - 200;
535
+
536
+ if (nearBottom) {
537
+ onLoadMore();
538
+ }
539
+ };
540
+
541
+ scrollElement.addEventListener("scroll", handleScroll, { passive: true });
542
+ // Also check immediately in case we're already at the bottom
543
+ handleScroll();
544
+
545
+ return () => scrollElement.removeEventListener("scroll", handleScroll);
546
+ }, [hasMore, isLoadingMore, onLoadMore]);
547
+
548
+ // Keyboard navigation. The dialog owns the keyboard while open, so list
549
+ // shortcuts pause to avoid double-handling (e.g. a second Delete press).
550
+ useKeyboardNavigation({
551
+ enabled: !isLoading && threads.length > 0 && pendingDeleteIds === null,
552
+ bindings: [
553
+ // Focus movement (plain). requireShift:false so Shift+Arrow falls
554
+ // through to the range-extend bindings below instead of also moving
555
+ // focus without selecting. j/k move the roving cursor WITHOUT opening
556
+ // the thread (#429) — only Enter / click open.
557
+ { key: "j", handler: focusNext, preventDefault: true },
558
+ {
559
+ key: "ArrowDown",
560
+ handler: focusNext,
561
+ preventDefault: true,
562
+ requireShift: false,
563
+ },
564
+ { key: "k", handler: focusPrevious, preventDefault: true },
565
+ {
566
+ key: "ArrowUp",
567
+ handler: focusPrevious,
568
+ preventDefault: true,
569
+ requireShift: false,
570
+ },
571
+ // Shift+Arrow: extend the selection range.
572
+ {
573
+ key: "ArrowDown",
574
+ handler: extendRangeDown,
575
+ preventDefault: true,
576
+ requireShift: true,
577
+ },
578
+ {
579
+ key: "ArrowUp",
580
+ handler: extendRangeUp,
581
+ preventDefault: true,
582
+ requireShift: true,
583
+ },
584
+ // Toggle focused row (x or Space) and re-anchor.
585
+ { key: "x", handler: toggleFocusedSelection, preventDefault: true },
586
+ { key: " ", handler: toggleFocusedSelection, preventDefault: true },
587
+ // Cmd/Ctrl+A select all loaded rows.
588
+ {
589
+ key: "a",
590
+ handler: handleSelectAll,
591
+ preventDefault: true,
592
+ requireMeta: true,
593
+ },
594
+ // Enter opens the focused message.
595
+ { key: "Enter", handler: handleOpenFocused, preventDefault: true },
596
+ // Delete / Backspace: confirm-delete selection or focused row.
597
+ { key: "Delete", handler: handleDeleteKey, preventDefault: true },
598
+ { key: "Backspace", handler: handleDeleteKey, preventDefault: true },
599
+ // d: toggle density (comfortable / compact).
600
+ { key: "d", handler: toggleDensity, preventDefault: true },
601
+ ],
602
+ });
603
+
604
+ // Esc-to-clear-selection on the capture phase, enabled only when a
605
+ // selection exists. Capture + stopPropagation makes a single Esc clear the
606
+ // selection and consume the keypress, so the route-level Esc (go back) does
607
+ // NOT also fire. With no selection this listener is disabled and Esc falls
608
+ // through to the route as before. The ConfirmDialog (when open) registers
609
+ // its own capture-phase Esc and pauses these list shortcuts, so its cancel
610
+ // still wins first.
611
+ useKeyboardNavigation({
612
+ enabled: hasSelection && pendingDeleteIds === null,
613
+ capture: true,
614
+ bindings: [
615
+ {
616
+ key: "Escape",
617
+ handler: clearSelection,
618
+ preventDefault: true,
619
+ stopPropagation: true,
620
+ },
621
+ ],
622
+ });
623
+
624
+ // Derive the MessageListPane listState from the loading/error/empty signals.
625
+ const listState = isLoading
626
+ ? "loading"
627
+ : isError
628
+ ? "error"
629
+ : threads.length === 0
630
+ ? "empty"
631
+ : "ready";
632
+
633
+ // Fail-loud (ux.md): surface the real failure under the error headline, and
634
+ // give it a place to go — the same GitHub-issue path the bug-report button
635
+ // uses, so the report carries app version, console errors and the URL.
636
+ const errorMessage = isError ? formatErrorMessage(error) : undefined;
637
+ const handleReportError = useCallback(() => {
638
+ const url = buildGitHubIssueUrl(buildBugReportContext());
639
+ window.open(url, "_blank", "noopener,noreferrer");
640
+ }, []);
641
+
642
+ // Single flat section — the mailbox view doesn't group by date.
643
+ const sections = [{ id: "inbox", threads: [] }];
644
+
645
+ // Desktop selection toolbar replaces the pane header when any rows are selected.
646
+ const desktopSelectionBar =
647
+ hasSelection && isDesktop ? (
648
+ <SelectionToolbar
649
+ selectedCount={selectedCount}
650
+ onDelete={handleDelete}
651
+ onClearSelection={clearSelection}
652
+ onMarkAsRead={onMarkAsRead ? handleMarkAsRead : undefined}
653
+ onMove={onMoveMessages ? handleMoveSelected : undefined}
654
+ onOrganize={() => setOrganizeOpen(true)}
655
+ isDeleting={isDeleting}
656
+ isMoving={isMoving}
657
+ accountId={accountId}
658
+ currentMailboxId={mailboxId}
659
+ moveDisabledHint={moveDisabledHint}
660
+ />
661
+ ) : undefined;
662
+
663
+ // Mobile multi-select bar replaces the pane header during selection mode.
664
+ const mobileSelectionBar =
665
+ isMultiSelectMode && !isDesktop ? (
666
+ <SelectionTopBar
667
+ count={selectedCount}
668
+ onCancel={handleCancelMultiSelect}
669
+ onDelete={handleDelete}
670
+ onMarkRead={onMarkAsRead ? handleMarkAsRead : undefined}
671
+ isBusy={isDeleting || isMoving}
672
+ moveDisabledHint={moveDisabledHint}
673
+ moveSlot={
674
+ onMoveMessages && accountId && mailboxId ? (
675
+ <>
676
+ {!moveDisabledHint && (
677
+ <button
678
+ type="button"
679
+ onClick={() => setOrganizeOpen(true)}
680
+ className="min-h-11 min-w-11 inline-flex shrink-0 items-center justify-center rounded text-fg-muted hover:bg-surface-raised"
681
+ aria-label="Organize similar messages"
682
+ >
683
+ <Sparkles className="size-4" />
684
+ </button>
685
+ )}
686
+ <MoveToTrigger
687
+ accountId={accountId}
688
+ currentMailboxId={mailboxId}
689
+ onMove={isDeleting || isMoving ? () => {} : handleMoveSelected}
690
+ disabledHint={moveDisabledHint}
691
+ label="Move selected messages"
692
+ />
693
+ </>
694
+ ) : undefined
695
+ }
696
+ />
697
+ ) : undefined;
698
+
699
+ const activeSelectionBar = desktopSelectionBar ?? mobileSelectionBar;
700
+
701
+ const isSearching = !!searchQuery?.trim();
702
+
703
+ // The virtualized list body: rows + search header + load-more indicator.
704
+ // Passed to MessageListPane as `listBody` so the kit provides the chrome
705
+ // (pane header, loading / empty / error states, keyboard hints) while we
706
+ // keep the @tanstack/react-virtual row recycling.
707
+ const virtualBody = (
708
+ <>
709
+ {isSearching && searchQuery && (
710
+ <SearchResultsHeader query={searchQuery} count={threads.length} />
711
+ )}
712
+ <div ref={parentRef} className="flex-1 overflow-y-auto">
713
+ <div
714
+ className="relative w-full"
715
+ style={{ height: `${virtualizer.getTotalSize()}px` }}
716
+ >
717
+ {virtualizer.getVirtualItems().map((virtualRow) => {
718
+ const thread = threads[virtualRow.index];
719
+ return (
720
+ <div
721
+ key={virtualRow.key}
722
+ data-index={virtualRow.index}
723
+ ref={virtualizer.measureElement}
724
+ className="absolute left-0 top-0 w-full border-b border-line"
725
+ style={{ transform: `translateY(${virtualRow.start}px)` }}
726
+ >
727
+ <SwipeableMessageRow
728
+ thread={thread}
729
+ mailboxId={mailboxId}
730
+ accountId={accountId}
731
+ isSelected={selectedMessageId === thread.messageId}
732
+ isFocused={focusedMessageId === thread.messageId}
733
+ isChecked={isChecked(thread.messageId)}
734
+ onToggleCheck={toggleCheck}
735
+ onRowSelect={handleRowSelect}
736
+ isMultiSelectMode={isMultiSelectMode}
737
+ onLongPress={handleLongPress}
738
+ isDesktop={isDesktop}
739
+ onDelete={handleSwipeDelete}
740
+ onToggleRead={handleSwipeToggleRead}
741
+ density={density}
742
+ />
743
+ </div>
744
+ );
745
+ })}
746
+ </div>
747
+ {isLoadingMore && (
748
+ <div className="flex justify-center py-4">
749
+ <div className="h-5 w-5 animate-spin rounded-full border-2 border-fg-muted border-t-transparent" />
750
+ </div>
751
+ )}
752
+ </div>
753
+ </>
754
+ );
755
+
756
+ return (
757
+ <>
758
+ <MessageListPane
759
+ listTitle={listTitle}
760
+ listMeta={listMeta}
761
+ sections={sections}
762
+ flatList
763
+ listState={listState}
764
+ searchQuery={isSearching ? searchQuery : undefined}
765
+ errorMessage={errorMessage}
766
+ onRetry={onRetry}
767
+ onReportError={handleReportError}
768
+ density={density}
769
+ selectedThreadId={selectedMessageId}
770
+ onSelectThread={(id) =>
771
+ navigate({
772
+ to: "/mail/$mailboxId",
773
+ params: { mailboxId },
774
+ search: (prev) => ({ ...prev, selectedMessageId: id }),
775
+ })
776
+ }
777
+ onSelectBriefCategory={() => undefined}
778
+ isDesktop={isDesktop}
779
+ hideHeader={hideHeader}
780
+ selectionBar={activeSelectionBar}
781
+ listBody={listState === "ready" ? virtualBody : undefined}
782
+ />
783
+ <ConfirmDialog
784
+ isOpen={pendingDeleteIds !== null}
785
+ title={formatDeleteToTrashTitle(pendingDeleteIds?.length ?? 0)}
786
+ description="You can restore them from Trash later."
787
+ confirmLabel="Move to Trash"
788
+ destructive
789
+ isBusy={isDeleting}
790
+ onConfirm={handleConfirmDelete}
791
+ onCancel={handleCancelDelete}
792
+ />
793
+ {organizeOpen && accountId && (
794
+ <OrganizeDialog
795
+ open={organizeOpen}
796
+ accountId={accountId}
797
+ mailboxId={mailboxId}
798
+ selectedMessageIds={Array.from(selectedIds)}
799
+ onClose={() => setOrganizeOpen(false)}
800
+ />
801
+ )}
802
+ </>
803
+ );
804
+ };