@rapidmx/web-client 0.9.0 → 0.11.0

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 (443) hide show
  1. package/README.md +373 -88
  2. package/apps/admin/_layout.tsx +8 -2
  3. package/apps/admin/index.tsx +7 -1
  4. package/apps/admin/ingest-queue/index.tsx +2 -1
  5. package/apps/admin/mailboxes/[uid].tsx +23 -10
  6. package/apps/admin/quarantine/index.tsx +2 -1
  7. package/apps/admin/signing-certificates/index.tsx +343 -0
  8. package/apps/escrow/_layout.tsx +8 -2
  9. package/apps/shared/appearance/AppearanceForm.tsx +392 -0
  10. package/apps/shared/appearance/AppearanceHead.tsx +49 -0
  11. package/apps/shared/appearance/AppearanceProvider.tsx +470 -0
  12. package/apps/shared/appearance/ColorField.tsx +107 -0
  13. package/apps/shared/appearance/appearanceCache.ts +106 -0
  14. package/apps/shared/appearance/appearanceContext.ts +77 -0
  15. package/apps/shared/appearance/bootScript.ts +35 -0
  16. package/apps/shared/appearance/color.ts +169 -0
  17. package/apps/shared/appearance/photo.ts +88 -0
  18. package/apps/shared/appearance/resolvedTheme.ts +67 -0
  19. package/apps/shared/appearance/theme.ts +355 -0
  20. package/apps/shared/auth/accountUrl.ts +9 -0
  21. package/apps/shared/auth/adminAccess.ts +99 -0
  22. package/apps/shared/components/admin/layout/AdminShell.tsx +22 -10
  23. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +107 -60
  24. package/apps/shared/components/admin/settings/BrandingForm.tsx +24 -0
  25. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +2 -1
  26. package/apps/shared/components/admin/settings/PluginsManager.tsx +228 -25
  27. package/apps/shared/components/admin/setup/SetupWizard.tsx +2 -1
  28. package/apps/shared/components/calendar/CalendarListSidebar.tsx +4 -5
  29. package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
  30. package/apps/shared/components/contacts/ContactForm.tsx +11 -5
  31. package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -3
  32. package/apps/shared/components/contacts/ContactsToolbar.tsx +98 -115
  33. package/apps/shared/components/contacts/layout/ContactsShell.tsx +25 -8
  34. package/apps/shared/components/escrow/layout/EscrowShell.tsx +13 -6
  35. package/apps/shared/components/layout/AppShell.tsx +477 -288
  36. package/apps/shared/components/layout/BrandingChrome.tsx +200 -13
  37. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
  38. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -164
  39. package/apps/shared/components/layout/RailIcon.tsx +100 -0
  40. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -0
  41. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -0
  42. package/apps/shared/components/layout/UserMenu.tsx +372 -185
  43. package/apps/shared/components/mail/ConversationList.tsx +271 -262
  44. package/apps/shared/components/mail/ConversationThreadPane.tsx +147 -94
  45. package/apps/shared/components/mail/LazyReadingPane.tsx +113 -0
  46. package/apps/shared/components/mail/MailSelectionBar.tsx +227 -222
  47. package/apps/shared/components/mail/MessageDetailPane.tsx +1537 -1387
  48. package/apps/shared/components/mail/OutboxBadge.tsx +44 -0
  49. package/apps/shared/components/mail/OutboxRowStatus.tsx +31 -0
  50. package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -160
  51. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
  52. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1644 -1736
  53. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
  54. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
  55. package/apps/shared/components/mail/compose/composePerf.ts +46 -0
  56. package/apps/shared/components/mail/compose/encryptionRequirement.ts +86 -0
  57. package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
  58. package/apps/shared/components/mail/layout/MailShell.tsx +490 -472
  59. package/apps/shared/components/mail/reading/EncryptedBody.tsx +65 -0
  60. package/apps/shared/components/mail/reading/EncryptedPreview.tsx +35 -0
  61. package/apps/shared/components/mail/reading/MessageBody.tsx +240 -0
  62. package/apps/shared/components/mail/reading/MessageCard.tsx +174 -0
  63. package/apps/shared/components/mail/reading/bodyContent.ts +100 -0
  64. package/apps/shared/components/mail/reading/bodyHtml.ts +298 -0
  65. package/apps/shared/components/mail/reading/color.ts +245 -0
  66. package/apps/shared/components/mail/reading/frameControl.ts +142 -0
  67. package/apps/shared/components/mail/reading/frameDocument.ts +123 -0
  68. package/apps/shared/components/mail/reading/safeDocument.ts +24 -0
  69. package/apps/shared/components/mail/reading/themeAdaptation.ts +221 -0
  70. package/apps/shared/components/mail/reading/themeSurface.ts +90 -0
  71. package/apps/shared/components/mail/reading/viewOriginal.ts +38 -0
  72. package/apps/shared/components/mail/unreadStyle.tsx +75 -0
  73. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -0
  74. package/apps/shared/components/settings/layout/SettingsShell.tsx +253 -240
  75. package/apps/shared/components/sharing/PrincipalPicker.tsx +145 -0
  76. package/apps/shared/components/tasks/TasksSidebar.tsx +5 -3
  77. package/apps/shared/components/tasks/layout/TasksShell.tsx +24 -8
  78. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
  79. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
  80. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
  81. package/apps/shared/keyboard/dispatch.ts +124 -0
  82. package/apps/shared/keyboard/format.ts +89 -0
  83. package/apps/shared/keyboard/keymap.ts +114 -0
  84. package/apps/shared/keyboard/match.ts +44 -0
  85. package/apps/shared/keyboard/parse.ts +136 -0
  86. package/apps/shared/keyboard/platform.ts +34 -0
  87. package/apps/shared/keyboard/registry.ts +65 -0
  88. package/apps/shared/keyboard/targets.ts +79 -0
  89. package/apps/shared/keyboard/useShortcut.ts +50 -0
  90. package/apps/shared/keyboard/useShortcutProps.ts +17 -0
  91. package/apps/shared/mail/folderCounts.ts +308 -0
  92. package/apps/shared/mail/folderTree.ts +143 -0
  93. package/apps/shared/mail/listSnapshots.ts +87 -0
  94. package/apps/shared/mail/messageReadState.ts +85 -0
  95. package/apps/shared/mail/newMailNotifications.ts +183 -0
  96. package/apps/shared/mail/outbox/UnlockBridge.tsx +23 -0
  97. package/apps/shared/mail/outbox/composeBridge.ts +69 -0
  98. package/apps/shared/mail/outbox/outboxState.ts +88 -0
  99. package/apps/shared/mail/outbox/pendingSends.ts +146 -0
  100. package/apps/shared/mail/outbox/sendDecision.ts +157 -0
  101. package/apps/shared/mail/outbox/sendJob.ts +427 -0
  102. package/apps/shared/mail/outbox/sendOutcomes.ts +146 -0
  103. package/apps/shared/mail/outbox/sendState.ts +35 -0
  104. package/apps/shared/mail/outbox/useOutboxStatus.ts +61 -0
  105. package/apps/shared/mail/useMailConnection.ts +203 -0
  106. package/apps/shared/mail/useMailLiveUpdates.ts +277 -224
  107. package/apps/shared/mail/useMarkMessageRead.ts +47 -0
  108. package/apps/shared/mail/useNewMailNotifications.ts +178 -0
  109. package/apps/shared/mail/useUnreadTitle.ts +42 -0
  110. package/apps/shared/navigation/AppRouter.tsx +300 -0
  111. package/apps/shared/navigation/appHrefs.ts +23 -0
  112. package/apps/shared/navigation/frameContext.tsx +35 -0
  113. package/apps/shared/navigation/idle.ts +45 -0
  114. package/apps/shared/navigation/routerContext.tsx +83 -0
  115. package/apps/shared/navigation/routes.ts +72 -0
  116. package/apps/shared/notifications/NotificationCenter.tsx +215 -0
  117. package/apps/shared/notifications/NotificationHistoryDialog.tsx +90 -0
  118. package/apps/shared/notifications/apiErrors.ts +98 -0
  119. package/apps/shared/notifications/headerOffset.ts +35 -0
  120. package/apps/shared/notifications/pushStatus.ts +56 -0
  121. package/apps/shared/notifications/store.ts +549 -0
  122. package/apps/shared/notifications/systemErrors.ts +63 -0
  123. package/apps/shared/notifications/useNotifications.ts +41 -0
  124. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
  125. package/apps/shared/signing/enrollmentStorage.ts +33 -0
  126. package/apps/shared/signing/enrollmentTracker.ts +385 -0
  127. package/apps/shared/signing/enrollmentView.ts +251 -0
  128. package/apps/shared/signing/signingInfo.ts +58 -0
  129. package/apps/shared/signing/useNow.ts +19 -0
  130. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -0
  131. package/apps/shared/styles/app.css +108 -10
  132. package/apps/www/_layout.tsx +8 -2
  133. package/apps/www/_routedPage.tsx +24 -0
  134. package/apps/www/_routes.ts +36 -0
  135. package/apps/www/calendar/index.tsx +64 -33
  136. package/apps/www/contacts/[uid].tsx +109 -107
  137. package/apps/www/contacts/index.tsx +62 -42
  138. package/apps/www/index.tsx +2291 -1917
  139. package/apps/www/messages/[uid].tsx +108 -101
  140. package/apps/www/settings/appearance/index.tsx +21 -0
  141. package/apps/www/settings/auto-reply/index.tsx +7 -8
  142. package/apps/www/settings/encryption/index.tsx +1287 -1249
  143. package/apps/www/settings/filters/[uid].tsx +7 -2
  144. package/apps/www/settings/filters/index.tsx +102 -99
  145. package/apps/www/settings/filters/new/index.tsx +140 -133
  146. package/apps/www/settings/labels/index.tsx +204 -201
  147. package/apps/www/settings/privacy/index.tsx +7 -9
  148. package/apps/www/settings/read-receipts/index.tsx +7 -8
  149. package/apps/www/settings/sharing/index.tsx +256 -274
  150. package/apps/www/settings/signatures/[uid].tsx +172 -167
  151. package/apps/www/settings/signatures/index.tsx +88 -85
  152. package/apps/www/settings/signatures/new/index.tsx +135 -129
  153. package/apps/www/tasks/index.tsx +29 -18
  154. package/dist/apps/admin/_layout.d.ts +5 -1
  155. package/dist/apps/admin/_layout.js +3 -2
  156. package/dist/apps/admin/index.js +3 -2
  157. package/dist/apps/admin/ingest-queue/index.js +2 -1
  158. package/dist/apps/admin/mailboxes/[uid].js +9 -6
  159. package/dist/apps/admin/quarantine/index.js +2 -1
  160. package/dist/apps/admin/signing-certificates/index.d.ts +3 -0
  161. package/dist/apps/admin/signing-certificates/index.js +161 -0
  162. package/dist/apps/escrow/_layout.d.ts +5 -1
  163. package/dist/apps/escrow/_layout.js +3 -2
  164. package/dist/apps/shared/appearance/AppearanceForm.d.ts +7 -0
  165. package/dist/apps/shared/appearance/AppearanceForm.js +131 -0
  166. package/dist/apps/shared/appearance/AppearanceHead.d.ts +15 -0
  167. package/dist/apps/shared/appearance/AppearanceHead.js +31 -0
  168. package/dist/apps/shared/appearance/AppearanceProvider.d.ts +32 -0
  169. package/dist/apps/shared/appearance/AppearanceProvider.js +390 -0
  170. package/dist/apps/shared/appearance/ColorField.d.ts +22 -0
  171. package/dist/apps/shared/appearance/ColorField.js +40 -0
  172. package/dist/apps/shared/appearance/appearanceCache.d.ts +45 -0
  173. package/dist/apps/shared/appearance/appearanceCache.js +80 -0
  174. package/dist/apps/shared/appearance/appearanceContext.d.ts +45 -0
  175. package/dist/apps/shared/appearance/appearanceContext.js +29 -0
  176. package/dist/apps/shared/appearance/bootScript.d.ts +16 -0
  177. package/dist/apps/shared/appearance/bootScript.js +33 -0
  178. package/dist/apps/shared/appearance/color.d.ts +65 -0
  179. package/dist/apps/shared/appearance/color.js +132 -0
  180. package/dist/apps/shared/appearance/photo.d.ts +17 -0
  181. package/dist/apps/shared/appearance/photo.js +79 -0
  182. package/dist/apps/shared/appearance/resolvedTheme.d.ts +21 -0
  183. package/dist/apps/shared/appearance/resolvedTheme.js +55 -0
  184. package/dist/apps/shared/appearance/theme.d.ts +111 -0
  185. package/dist/apps/shared/appearance/theme.js +251 -0
  186. package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
  187. package/dist/apps/shared/auth/accountUrl.js +8 -0
  188. package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
  189. package/dist/apps/shared/auth/adminAccess.js +89 -0
  190. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  191. package/dist/apps/shared/components/admin/layout/AdminShell.js +16 -6
  192. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +10 -5
  193. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +49 -34
  194. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +2 -0
  195. package/dist/apps/shared/components/admin/settings/BrandingForm.js +7 -1
  196. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +1 -1
  197. package/dist/apps/shared/components/admin/settings/PluginsManager.js +106 -20
  198. package/dist/apps/shared/components/admin/setup/SetupWizard.js +2 -1
  199. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +8 -8
  200. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
  201. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
  202. package/dist/apps/shared/components/contacts/ContactForm.js +1 -1
  203. package/dist/apps/shared/components/contacts/ContactsSidebar.js +4 -2
  204. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +6 -2
  205. package/dist/apps/shared/components/contacts/ContactsToolbar.js +28 -10
  206. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
  207. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +19 -6
  208. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +7 -4
  209. package/dist/apps/shared/components/layout/AppShell.d.ts +34 -4
  210. package/dist/apps/shared/components/layout/AppShell.js +122 -17
  211. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +69 -10
  212. package/dist/apps/shared/components/layout/BrandingChrome.js +140 -12
  213. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
  214. package/dist/apps/shared/components/layout/MailboxProvisioning.js +3 -1
  215. package/dist/apps/shared/components/layout/RailIcon.d.ts +21 -0
  216. package/dist/apps/shared/components/layout/RailIcon.js +85 -0
  217. package/dist/apps/shared/components/layout/ResponsiveToolbar.d.ts +55 -0
  218. package/dist/apps/shared/components/layout/ResponsiveToolbar.js +199 -0
  219. package/dist/apps/shared/components/layout/ThemeSwitch.d.ts +9 -0
  220. package/dist/apps/shared/components/layout/ThemeSwitch.js +54 -0
  221. package/dist/apps/shared/components/layout/UserMenu.d.ts +25 -3
  222. package/dist/apps/shared/components/layout/UserMenu.js +88 -7
  223. package/dist/apps/shared/components/mail/ConversationList.js +5 -11
  224. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +6 -2
  225. package/dist/apps/shared/components/mail/ConversationThreadPane.js +87 -40
  226. package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
  227. package/dist/apps/shared/components/mail/LazyReadingPane.js +83 -0
  228. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +6 -4
  229. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -4
  230. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +25 -8
  231. package/dist/apps/shared/components/mail/MessageDetailPane.js +167 -73
  232. package/dist/apps/shared/components/mail/OutboxBadge.d.ts +17 -0
  233. package/dist/apps/shared/components/mail/OutboxBadge.js +18 -0
  234. package/dist/apps/shared/components/mail/OutboxRowStatus.d.ts +10 -0
  235. package/dist/apps/shared/components/mail/OutboxRowStatus.js +19 -0
  236. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +38 -0
  237. package/dist/apps/shared/components/mail/compose/ComposeContext.js +97 -6
  238. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
  239. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +3 -2
  240. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +316 -339
  241. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
  242. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
  243. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
  244. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
  245. package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
  246. package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
  247. package/dist/apps/shared/components/mail/compose/encryptionRequirement.d.ts +49 -0
  248. package/dist/apps/shared/components/mail/compose/encryptionRequirement.js +35 -0
  249. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
  250. package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
  251. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +14 -4
  252. package/dist/apps/shared/components/mail/layout/MailShell.js +90 -104
  253. package/dist/apps/shared/components/mail/reading/EncryptedBody.d.ts +21 -0
  254. package/dist/apps/shared/components/mail/reading/EncryptedBody.js +22 -0
  255. package/dist/apps/shared/components/mail/reading/EncryptedPreview.d.ts +18 -0
  256. package/dist/apps/shared/components/mail/reading/EncryptedPreview.js +22 -0
  257. package/dist/apps/shared/components/mail/reading/MessageBody.d.ts +32 -0
  258. package/dist/apps/shared/components/mail/reading/MessageBody.js +136 -0
  259. package/dist/apps/shared/components/mail/reading/MessageCard.d.ts +62 -0
  260. package/dist/apps/shared/components/mail/reading/MessageCard.js +58 -0
  261. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +38 -0
  262. package/dist/apps/shared/components/mail/reading/bodyContent.js +84 -0
  263. package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +47 -0
  264. package/dist/apps/shared/components/mail/reading/bodyHtml.js +267 -0
  265. package/dist/apps/shared/components/mail/reading/color.d.ts +63 -0
  266. package/dist/apps/shared/components/mail/reading/color.js +201 -0
  267. package/dist/apps/shared/components/mail/reading/frameControl.d.ts +33 -0
  268. package/dist/apps/shared/components/mail/reading/frameControl.js +120 -0
  269. package/dist/apps/shared/components/mail/reading/frameDocument.d.ts +52 -0
  270. package/dist/apps/shared/components/mail/reading/frameDocument.js +98 -0
  271. package/dist/apps/shared/components/mail/reading/safeDocument.d.ts +3 -0
  272. package/dist/apps/shared/components/mail/reading/safeDocument.js +12 -0
  273. package/dist/apps/shared/components/mail/reading/themeAdaptation.d.ts +56 -0
  274. package/dist/apps/shared/components/mail/reading/themeAdaptation.js +144 -0
  275. package/dist/apps/shared/components/mail/reading/themeSurface.d.ts +17 -0
  276. package/dist/apps/shared/components/mail/reading/themeSurface.js +83 -0
  277. package/dist/apps/shared/components/mail/reading/viewOriginal.d.ts +9 -0
  278. package/dist/apps/shared/components/mail/reading/viewOriginal.js +32 -0
  279. package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
  280. package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
  281. package/dist/apps/shared/components/settings/SigningCertificateCard.d.ts +32 -0
  282. package/dist/apps/shared/components/settings/SigningCertificateCard.js +98 -0
  283. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
  284. package/dist/apps/shared/components/settings/layout/SettingsShell.js +16 -5
  285. package/dist/apps/shared/components/sharing/PrincipalPicker.d.ts +26 -0
  286. package/dist/apps/shared/components/sharing/PrincipalPicker.js +77 -0
  287. package/dist/apps/shared/components/tasks/TasksSidebar.js +4 -2
  288. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
  289. package/dist/apps/shared/components/tasks/layout/TasksShell.js +18 -6
  290. package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
  291. package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
  292. package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
  293. package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
  294. package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
  295. package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
  296. package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
  297. package/dist/apps/shared/keyboard/dispatch.js +109 -0
  298. package/dist/apps/shared/keyboard/format.d.ts +12 -0
  299. package/dist/apps/shared/keyboard/format.js +74 -0
  300. package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
  301. package/dist/apps/shared/keyboard/keymap.js +84 -0
  302. package/dist/apps/shared/keyboard/match.d.ts +20 -0
  303. package/dist/apps/shared/keyboard/match.js +31 -0
  304. package/dist/apps/shared/keyboard/parse.d.ts +31 -0
  305. package/dist/apps/shared/keyboard/parse.js +107 -0
  306. package/dist/apps/shared/keyboard/platform.d.ts +15 -0
  307. package/dist/apps/shared/keyboard/platform.js +22 -0
  308. package/dist/apps/shared/keyboard/registry.d.ts +39 -0
  309. package/dist/apps/shared/keyboard/registry.js +33 -0
  310. package/dist/apps/shared/keyboard/targets.d.ts +14 -0
  311. package/dist/apps/shared/keyboard/targets.js +67 -0
  312. package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
  313. package/dist/apps/shared/keyboard/useShortcut.js +35 -0
  314. package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
  315. package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
  316. package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
  317. package/dist/apps/shared/mail/folderCounts.js +218 -0
  318. package/dist/apps/shared/mail/folderTree.d.ts +55 -0
  319. package/dist/apps/shared/mail/folderTree.js +116 -0
  320. package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
  321. package/dist/apps/shared/mail/listSnapshots.js +43 -0
  322. package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
  323. package/dist/apps/shared/mail/messageReadState.js +63 -0
  324. package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
  325. package/dist/apps/shared/mail/newMailNotifications.js +138 -0
  326. package/dist/apps/shared/mail/outbox/UnlockBridge.d.ts +5 -0
  327. package/dist/apps/shared/mail/outbox/UnlockBridge.js +18 -0
  328. package/dist/apps/shared/mail/outbox/composeBridge.d.ts +34 -0
  329. package/dist/apps/shared/mail/outbox/composeBridge.js +32 -0
  330. package/dist/apps/shared/mail/outbox/outboxState.d.ts +41 -0
  331. package/dist/apps/shared/mail/outbox/outboxState.js +42 -0
  332. package/dist/apps/shared/mail/outbox/pendingSends.d.ts +39 -0
  333. package/dist/apps/shared/mail/outbox/pendingSends.js +109 -0
  334. package/dist/apps/shared/mail/outbox/sendDecision.d.ts +80 -0
  335. package/dist/apps/shared/mail/outbox/sendDecision.js +94 -0
  336. package/dist/apps/shared/mail/outbox/sendJob.d.ts +68 -0
  337. package/dist/apps/shared/mail/outbox/sendJob.js +319 -0
  338. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +3 -0
  339. package/dist/apps/shared/mail/outbox/sendOutcomes.js +141 -0
  340. package/dist/apps/shared/mail/outbox/sendState.d.ts +25 -0
  341. package/dist/apps/shared/mail/outbox/sendState.js +8 -0
  342. package/dist/apps/shared/mail/outbox/useOutboxStatus.d.ts +11 -0
  343. package/dist/apps/shared/mail/outbox/useOutboxStatus.js +49 -0
  344. package/dist/apps/shared/mail/useMailConnection.d.ts +56 -0
  345. package/dist/apps/shared/mail/useMailConnection.js +138 -0
  346. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +29 -9
  347. package/dist/apps/shared/mail/useMailLiveUpdates.js +60 -32
  348. package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
  349. package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
  350. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +35 -0
  351. package/dist/apps/shared/mail/useNewMailNotifications.js +130 -0
  352. package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
  353. package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
  354. package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
  355. package/dist/apps/shared/navigation/AppRouter.js +242 -0
  356. package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
  357. package/dist/apps/shared/navigation/appHrefs.js +20 -0
  358. package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
  359. package/dist/apps/shared/navigation/frameContext.js +24 -0
  360. package/dist/apps/shared/navigation/idle.d.ts +14 -0
  361. package/dist/apps/shared/navigation/idle.js +43 -0
  362. package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
  363. package/dist/apps/shared/navigation/routerContext.js +56 -0
  364. package/dist/apps/shared/navigation/routes.d.ts +32 -0
  365. package/dist/apps/shared/navigation/routes.js +37 -0
  366. package/dist/apps/shared/notifications/NotificationCenter.d.ts +19 -0
  367. package/dist/apps/shared/notifications/NotificationCenter.js +90 -0
  368. package/dist/apps/shared/notifications/NotificationHistoryDialog.d.ts +10 -0
  369. package/dist/apps/shared/notifications/NotificationHistoryDialog.js +36 -0
  370. package/dist/apps/shared/notifications/apiErrors.d.ts +19 -0
  371. package/dist/apps/shared/notifications/apiErrors.js +86 -0
  372. package/dist/apps/shared/notifications/headerOffset.d.ts +8 -0
  373. package/dist/apps/shared/notifications/headerOffset.js +33 -0
  374. package/dist/apps/shared/notifications/pushStatus.d.ts +9 -0
  375. package/dist/apps/shared/notifications/pushStatus.js +54 -0
  376. package/dist/apps/shared/notifications/store.d.ts +136 -0
  377. package/dist/apps/shared/notifications/store.js +416 -0
  378. package/dist/apps/shared/notifications/systemErrors.d.ts +9 -0
  379. package/dist/apps/shared/notifications/systemErrors.js +58 -0
  380. package/dist/apps/shared/notifications/useNotifications.d.ts +17 -0
  381. package/dist/apps/shared/notifications/useNotifications.js +18 -0
  382. package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
  383. package/dist/apps/shared/signing/enrollmentStorage.d.ts +10 -0
  384. package/dist/apps/shared/signing/enrollmentStorage.js +33 -0
  385. package/dist/apps/shared/signing/enrollmentTracker.d.ts +73 -0
  386. package/dist/apps/shared/signing/enrollmentTracker.js +307 -0
  387. package/dist/apps/shared/signing/enrollmentView.d.ts +57 -0
  388. package/dist/apps/shared/signing/enrollmentView.js +214 -0
  389. package/dist/apps/shared/signing/signingInfo.d.ts +11 -0
  390. package/dist/apps/shared/signing/signingInfo.js +48 -0
  391. package/dist/apps/shared/signing/useNow.d.ts +2 -0
  392. package/dist/apps/shared/signing/useNow.js +18 -0
  393. package/dist/apps/shared/signing/useSigningEnrollmentWatcher.d.ts +16 -0
  394. package/dist/apps/shared/signing/useSigningEnrollmentWatcher.js +87 -0
  395. package/dist/apps/shared/styles/app.css +108 -10
  396. package/dist/apps/www/_layout.d.ts +5 -1
  397. package/dist/apps/www/_layout.js +3 -2
  398. package/dist/apps/www/_routedPage.d.ts +12 -0
  399. package/dist/apps/www/_routedPage.js +19 -0
  400. package/dist/apps/www/_routes.d.ts +11 -0
  401. package/dist/apps/www/_routes.js +30 -0
  402. package/dist/apps/www/calendar/index.d.ts +2 -2
  403. package/dist/apps/www/calendar/index.js +41 -11
  404. package/dist/apps/www/contacts/[uid].d.ts +3 -9
  405. package/dist/apps/www/contacts/[uid].js +11 -9
  406. package/dist/apps/www/contacts/index.d.ts +2 -2
  407. package/dist/apps/www/contacts/index.js +30 -24
  408. package/dist/apps/www/index.d.ts +2 -2
  409. package/dist/apps/www/index.js +332 -48
  410. package/dist/apps/www/messages/[uid].d.ts +3 -7
  411. package/dist/apps/www/messages/[uid].js +10 -5
  412. package/dist/apps/www/settings/appearance/index.d.ts +4 -0
  413. package/dist/apps/www/settings/appearance/index.js +13 -0
  414. package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
  415. package/dist/apps/www/settings/auto-reply/index.js +6 -7
  416. package/dist/apps/www/settings/encryption/index.d.ts +2 -2
  417. package/dist/apps/www/settings/encryption/index.js +106 -96
  418. package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
  419. package/dist/apps/www/settings/filters/[uid].js +6 -2
  420. package/dist/apps/www/settings/filters/index.d.ts +2 -2
  421. package/dist/apps/www/settings/filters/index.js +3 -1
  422. package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
  423. package/dist/apps/www/settings/filters/new/index.js +9 -3
  424. package/dist/apps/www/settings/labels/index.d.ts +2 -2
  425. package/dist/apps/www/settings/labels/index.js +3 -1
  426. package/dist/apps/www/settings/privacy/index.d.ts +2 -2
  427. package/dist/apps/www/settings/privacy/index.js +8 -9
  428. package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
  429. package/dist/apps/www/settings/read-receipts/index.js +6 -7
  430. package/dist/apps/www/settings/sharing/index.d.ts +2 -2
  431. package/dist/apps/www/settings/sharing/index.js +22 -34
  432. package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
  433. package/dist/apps/www/settings/signatures/[uid].js +6 -2
  434. package/dist/apps/www/settings/signatures/index.d.ts +2 -2
  435. package/dist/apps/www/settings/signatures/index.js +3 -1
  436. package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
  437. package/dist/apps/www/settings/signatures/new/index.js +9 -4
  438. package/dist/apps/www/tasks/index.d.ts +2 -2
  439. package/dist/apps/www/tasks/index.js +22 -16
  440. package/package.json +2 -2
  441. package/apps/shared/components/mail/compose/SendFailureAlert.tsx +0 -48
  442. package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +0 -14
  443. package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +0 -11
@@ -1,1917 +1,2291 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { useCallback, useEffect, useRef, useState } from "react";
6
- import { HiOutlineFlag, HiOutlineLockClosed, HiOutlinePaperClip } from "react-icons/hi2";
7
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
- import {
9
- Folder,
10
- Mailbox,
11
- Message,
12
- MessageListFilter,
13
- MessageListParams,
14
- archiveMessage,
15
- bulkUpdateMessages,
16
- createFolder,
17
- getMessage,
18
- getMessageRawContent,
19
- listMessages,
20
- moveMessages,
21
- setMessagesFlagged,
22
- setMessagesRead,
23
- } from "@rapidmx/react-shared/mail/mailApi.js";
24
- import { Label, listLabels } from "@rapidmx/react-shared/mail/labelsApi.js";
25
- import {
26
- ConversationListParams,
27
- ConversationSummary,
28
- listConversationMessages,
29
- listConversations,
30
- } from "@rapidmx/react-shared/mail/conversationsApi.js";
31
- import { SearchResult, search as searchMailbox } from "@rapidmx/react-shared/search/searchApi.js";
32
- import { parseSearchQuery, type ParsedSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
33
- import { normalizeServerScores } from "@rapidmx/react-shared/search/searchScoring.js";
34
- import { searchEncryptedCandidates } from "@rapidmx/react-shared/search/searchTier3.js";
35
- import { searchLocalIndex } from "../shared/search/searchTier2.js";
36
- import type { Coverage } from "../shared/search/localIndexWorker.js";
37
- import { getUnlockedKeys, subscribeKeySession, UnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
38
- import { evaluateMessageSecurity } from "@rapidmx/react-shared/crypto/messageSecurity.js";
39
- import { useMarkMessageRead, useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
40
- import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
41
- import MailShell, {
42
- AggregateFolderType,
43
- MailboxFolders,
44
- MailShellProps,
45
- useMailShell,
46
- } from "../shared/components/mail/layout/MailShell.js";
47
- import MailAddress from "../shared/components/mail/MailAddress.js";
48
- import { mergeFirstPage } from "../shared/mail/mergeFirstPage.js";
49
- import MessageDetailPane from "../shared/components/mail/MessageDetailPane.js";
50
- import ConversationList from "../shared/components/mail/ConversationList.js";
51
- import ConversationThreadPane from "../shared/components/mail/ConversationThreadPane.js";
52
- import MailListToolbar from "../shared/components/mail/MailListToolbar.js";
53
- import MailSelectionBar from "../shared/components/mail/MailSelectionBar.js";
54
- import {
55
- CONVERSATION_SORT_NOTE,
56
- CONVERSATION_SORT_UNAVAILABLE,
57
- MAIL_LIST_CLASSIFICATION_FILTERS,
58
- MailListPreferences,
59
- getMailListPreferences,
60
- setMailListPreferences,
61
- sortConversations,
62
- } from "../shared/components/mail/listPreferences.js";
63
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
64
- import Skeleton from "@rapidmx/react-shared/components/feedback/Skeleton.js";
65
- import { useUnlockPrompt } from "../shared/components/layout/UnlockPromptProvider.js";
66
-
67
- const MESSAGE_PAGE_SIZE = 50;
68
- const SEARCH_DEBOUNCE_MS = 300;
69
- const LIST_PREVIEW_MAX_LENGTH = 160;
70
-
71
- /** The literal outer-envelope `Subject` every encrypted message carries server-side - RFC 9788's
72
- * `hcp_baseline` policy obscures it to this exact string (see `smimeMessage.ts`'s
73
- * `applyBaselineOuterHeaders()`), which is also all `Message.subject` ever shows for one of these until
74
- * decrypted client-side. Used here to recognize which loaded rows are worth decrypting for display. */
75
- const ENCRYPTED_SUBJECT_PLACEHOLDER = "[...]";
76
-
77
- /** A row's client-recovered subject/preview, once decrypted - `undefined` fields mean nothing better
78
- * than the placeholder/blank server value was recoverable for that field specifically. */
79
- interface DecryptedRow {
80
- subject?: string;
81
- preview?: string;
82
- }
83
-
84
- /** Strips HTML down to plain text, for a short list-row preview of a decrypted body - mirrors
85
- * `searchTier3.ts`'s own private `stripHtml()` (not currently exported from `@rapidmx/react-shared`,
86
- * so duplicated here rather than pulled in through a package change just for this one small, pure
87
- * helper). Not a security boundary - the output only ever feeds plain text display, truncated below,
88
- * never rendered back into any DOM. */
89
- function stripHtmlToText(html: string): string {
90
- return html
91
- .replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, " ")
92
- .replace(/<[^>]+>/g, " ")
93
- .replace(/&nbsp;/gi, " ")
94
- .replace(/&amp;/gi, "&")
95
- .replace(/&lt;/gi, "<")
96
- .replace(/&gt;/gi, ">")
97
- .replace(/&quot;/gi, '"')
98
- .replace(/&#0*39;/gi, "'")
99
- .replace(/\s+/g, " ")
100
- .trim();
101
- }
102
-
103
- /**
104
- * Decrypts the subject/preview of every currently-loaded row whose subject is still the RFC 9788
105
- * placeholder (i.e. every encrypted message this device hasn't already resolved), keyed by uid - the
106
- * inbox-list counterpart to `MessageDetailPane`'s own single-message decrypt and `searchTier3.ts`'s
107
- * per-candidate decrypt. Bounded to `messages` (at most one loaded page, `MESSAGE_PAGE_SIZE`), never the
108
- * whole mailbox - matching `searchEncryptedCandidates()`'s own "bounded, not everything" scope. Runs only
109
- * once `unlocked` is available (the caller decides when to call this - see `InboxContent`'s own effect
110
- * and `handleUnlockList()`), and a single row's fetch/decrypt failure never blocks the rest.
111
- */
112
- async function decryptEncryptedRows(messages: Message[], unlocked: UnlockedKeys): Promise<Record<string, DecryptedRow>> {
113
- const encrypted = messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER);
114
- const entries = await Promise.all(
115
- encrypted.map(async (message): Promise<[string, DecryptedRow] | null> => {
116
- try {
117
- const rawMime = await getMessageRawContent(message.uid);
118
- const security = await evaluateMessageSecurity(rawMime, unlocked);
119
- if (!security.subject && !security.html) {
120
- return null;
121
- }
122
- const preview = security.html ? stripHtmlToText(security.html).slice(0, LIST_PREVIEW_MAX_LENGTH) : undefined;
123
- return [message.uid, { subject: security.subject, preview }];
124
- } catch {
125
- return null;
126
- }
127
- }),
128
- );
129
- const result: Record<string, DecryptedRow> = {};
130
- for (const entry of entries) {
131
- if (entry) {
132
- result[entry[0]] = entry[1];
133
- }
134
- }
135
- return result;
136
- }
137
-
138
- /** Merges Tier 1 (server, possibly `metadataOnly` for an encrypted message), Tier 2 (local index, fully
139
- * decrypted and re-scored), and Tier 3 (server-narrowed candidates, decrypted and re-scored) results into
140
- * one ranked list, per `specs/search.md` §7's "client MUST re-score all results it can see... normalise
141
- * into the same space rather than interleaving raw scores": each tier is normalized independently via
142
- * `normalizeServerScores()` before merging, since a Postgres/OpenSearch score, a local `bm25()` score,
143
- * and this module's own Tier 3 term-count score all occupy unrelated ranges. A uid present in more than
144
- * one list keeps only the last-inserted entry (Tier 2 wins over Tier 3 wins over Tier 1) - Tier 2 and
145
- * Tier 3 both represent genuine, content-verified scores for the same message, so which one "wins" on
146
- * overlap doesn't change correctness, only which of two equally-valid scores is shown; either supersedes
147
- * Tier 1's metadata-only guess for the same uid.
148
- *
149
- * Called progressively - once per tier as it resolves, each time with whatever tiers have reported so
150
- * far (an empty array for the rest) - by `InboxContent`'s own search orchestration below, per §_Progressive
151
- * Results_' "reordering is permitted and preferred over appending." This function itself stays pure and
152
- * stateless; it has no notion of "in progress" versus "final." */
153
- function mergeSearchResults(tier1: SearchResult[], tier2: SearchResult[], tier3: SearchResult[]): SearchResult[] {
154
- const normalizedTier1 = normalizeServerScores(tier1);
155
- const normalizedTier2 = normalizeServerScores(tier2);
156
- const normalizedTier3 = normalizeServerScores(tier3);
157
- const merged = new Map<string, { result: SearchResult; normalizedScore: number }>();
158
- for (const entry of normalizedTier1) {
159
- merged.set(entry.result.entityUid, entry);
160
- }
161
- for (const entry of normalizedTier3) {
162
- merged.set(entry.result.entityUid, entry);
163
- }
164
- for (const entry of normalizedTier2) {
165
- merged.set(entry.result.entityUid, entry);
166
- }
167
- return Array.from(merged.values())
168
- .sort((a, b) => b.normalizedScore - a.normalizedScore)
169
- .map((entry) => entry.result);
170
- }
171
-
172
- /** `type:` narrows `entityTypes`; when absent this still defaults to `["message"]` — a non-message hit
173
- * (contact/calendarEvent/note/task) has no `Message` to resolve via `getMessage()` below and is simply
174
- * dropped by the same eventually-consistent-index fallback that already existed, rather than rendered
175
- * (this inbox list only ever shows message rows; a real multi-entity-type results view is a separate,
176
- * larger UI project outside this pass). Shared by both the fresh-search orchestration and `loadMore()`
177
- * below, which each build this from the same `ParsedSearchQuery` differently only in `cursor`. */
178
- function tier1SearchParams(parsed: ParsedSearchQuery, cursor: string | undefined, mailboxUid: string) {
179
- return {
180
- // The mailbox actually open - omitted, the server searches the caller's *own* mailbox, which is the
181
- // wrong one whenever a shared mailbox's folder is being viewed.
182
- mailboxUid,
183
- types: parsed.entityTypes ?? ["message"],
184
- cursor,
185
- limit: MESSAGE_PAGE_SIZE,
186
- from: parsed.from,
187
- to: parsed.to,
188
- cc: parsed.cc,
189
- subject: parsed.subject,
190
- hasAttachment: parsed.hasAttachment,
191
- before: parsed.before,
192
- after: parsed.after,
193
- folderUid: parsed.folderUid,
194
- flags: parsed.flags,
195
- labels: parsed.labels,
196
- };
197
- }
198
-
199
- /** How many of Tier 1's own `metadataOnly` hits (§7: "an encrypted entity matched only on server-visible
200
- * metadata... MUST be rendered as skeleton entries in place... using the metadata score as a provisional
201
- * position") are shown as skeleton rows at once - §_Progressive Results_' "Skeletons MUST be capped, at
202
- * approximately one and a half pages." A non-`metadataOnly` Tier 1 hit (a real, already-scored content
203
- * match — always the case for unencrypted mail) is never a skeleton and is never subject to this cap.
204
- *
205
- * A Tier 3 candidate that Tier 1 did *not* already surface has no provisional score/position of its own
206
- * under the spec's own wording above, so it is deliberately never pre-rendered as a skeleton here either
207
- * - it simply appears, fully resolved, once Tier 3 confirms it (see `InboxContent`'s search
208
- * orchestration). */
209
- const SKELETON_CAP = Math.round(MESSAGE_PAGE_SIZE * 1.5);
210
-
211
- function capSkeletons(tier1Hits: SearchResult[]): SearchResult[] {
212
- let skeletonsSeen = 0;
213
- return tier1Hits.filter((hit) => {
214
- if (!hit.metadataOnly) {
215
- return true;
216
- }
217
- skeletonsSeen += 1;
218
- return skeletonsSeen <= SKELETON_CAP;
219
- });
220
- }
221
-
222
- /** How many candidates Tier 3 pulls per distinct search - larger than one page's worth so several
223
- * `loadMore()` pages can be sliced from one decrypt pass (see `Tier3Cache` below) instead of a second,
224
- * separately expensive server round trip and re-decrypt for the same query. Bounded, not unlimited - per
225
- * this module's own `tier1SearchParams()` sibling, Tier 3's own candidate-narrowing is already the
226
- * "heaviest single client-side cost" tier (`specs/search.md` §9's identical framing for attachment
227
- * extraction); a query whose true candidate set exceeds this simply pages out once this cache is
228
- * exhausted; `loadMore()` reflects that honestly via `hasMore`. */
229
- const TIER3_CANDIDATE_LIMIT = 200;
230
-
231
- /** One entry per distinct (mailbox, query, "search all mail" toggle, unlocked-or-not) combination this
232
- * tab has already run Tier 3 for - keyed by `tier3CacheKey()` below. Tier 3's decrypt-and-match pass
233
- * (`searchEncryptedCandidates()`) is by far this search's most expensive step, so it runs once per
234
- * combination; every subsequent `loadMore()` page for that same combination slices further into the
235
- * same already-decrypted array. Session-scoped, per tab, with no explicit eviction - a small map that's
236
- * simply never read again once the query changes, the same shape `InboxContent`'s own `decryptedRows`
237
- * state already accepts for a similar "worth keeping around, not worth actively pruning" tradeoff.
238
- *
239
- * The unlocked-or-not dimension matters: re-running an identical query right after an on-demand unlock
240
- * (`handleUnlockSearch()`) MUST NOT reuse the "nothing to contribute" entry that same query cached while
241
- * still locked - `searchEncryptedCandidates()` degrades to `[]` for an absent `unlocked`, and that empty
242
- * result is exactly as cacheable/reusable as a real one, just under a different key. */
243
- type Tier3Cache = Map<string, SearchResult[]>;
244
-
245
- /** Keyed by the exact query windows Tier 3 actually ran (`tier3Windows()` - which already reflect the query,
246
- * the "Search all mail" toggle, and Tier 2's coverage at the time), not just the query: the same query
247
- * narrowed differently (a build finished, new mail moved the coverage end) must not reuse a result that was
248
- * computed over a different date range. */
249
- function tier3CacheKey(mailboxUid: string, windows: ParsedSearchQuery[], unlocked: boolean): string {
250
- return `${mailboxUid}|${JSON.stringify(windows)}|${String(unlocked)}`;
251
- }
252
-
253
- /** A search query's cache/cursor identity - stable across re-parsing the identical raw text, and
254
- * distinct for anything else (§8's "a query fingerprint, so a cursor cannot be replayed against a
255
- * different query"). `JSON.stringify` on `ParsedSearchQuery` is deterministic here because every one of
256
- * its own fields is a primitive or a `Date` (which serializes to a fixed ISO string) - no nested object
257
- * whose key order could vary between two structurally-identical parses of the same text. */
258
- function queryFingerprint(parsed: ParsedSearchQuery): string {
259
- return JSON.stringify(parsed);
260
- }
261
-
262
- /** The date windows Tier 3 still has to search: the query's own range minus Tier 2's guaranteed coverage
263
- * `[coverage.indexedFrom, coverage.indexedUntil]`, unless the reader explicitly asked to "Search all mail".
264
- * Tier 2 already holds fully-decrypted, current content for that range, so re-fetching and re-decrypting it
265
- * through Tier 3's slower candidate-narrowing path would be pure waste. Yields up to two windows - older than
266
- * the coverage (`before:` tightened to `indexedFrom`) and newer than it (`after:` raised to `indexedUntil`,
267
- * since nothing indexes mail that arrived after the build pass started) - never *widening* a bound the query
268
- * already specified, and none at all when the query lies entirely inside the coverage.
269
- *
270
- * Only narrows once Tier 2 reports a finished, complete build pass from this session: while it's still
271
- * building (or a pass stopped early - a failed folder listing, the byte budget) `indexedFrom` is just the
272
- * oldest row that happens to be present, not a guarantee every encrypted message since then is indexed, and
273
- * narrowing on it would silently drop encrypted results neither tier returns. */
274
- function tier3Windows(parsed: ParsedSearchQuery, coverage: Coverage | undefined, searchAllMail: boolean): ParsedSearchQuery[] {
275
- if (searchAllMail || !coverage?.indexedFrom || !coverage.indexedUntil || coverage.building || !coverage.complete) {
276
- return [parsed];
277
- }
278
- const coveredFrom = new Date(coverage.indexedFrom);
279
- const coveredUntil = new Date(coverage.indexedUntil);
280
- const windows: ParsedSearchQuery[] = [];
281
- if (!parsed.after || parsed.after.getTime() < coveredFrom.getTime()) {
282
- windows.push({ ...parsed, before: parsed.before && parsed.before.getTime() < coveredFrom.getTime() ? parsed.before : coveredFrom });
283
- }
284
- if (!parsed.before || parsed.before.getTime() > coveredUntil.getTime()) {
285
- windows.push({ ...parsed, after: parsed.after && parsed.after.getTime() > coveredUntil.getTime() ? parsed.after : coveredUntil });
286
- }
287
- return windows;
288
- }
289
-
290
- /** Runs Tier 3 over each window and merges the candidates (a uid can't match in two disjoint windows, but a
291
- * message whose date sits exactly on a boundary may come back from both). */
292
- async function searchTier3Windows(windows: ParsedSearchQuery[], unlocked: UnlockedKeys | undefined, mailboxUid: string): Promise<SearchResult[]> {
293
- const pages = await Promise.all(
294
- windows.map((window) => searchEncryptedCandidates(window, unlocked, TIER3_CANDIDATE_LIMIT, { mailboxUid })),
295
- );
296
- const merged = new Map<string, SearchResult>();
297
- for (const result of pages.flat()) {
298
- if (!merged.has(result.entityUid)) {
299
- merged.set(result.entityUid, result);
300
- }
301
- }
302
- return [...merged.values()];
303
- }
304
-
305
- /** The paging state for one search, composited across all three tiers (`specs/search.md` §8) - opaque to
306
- * every caller the same way `SearchResultPage.nextCursor` is opaque to callers of `search()` itself.
307
- * Tier 1 keeps the server's own opaque cursor unmodified; Tier 2 (the local index) and Tier 3 (the
308
- * cached, already-decrypted candidate array - see `Tier3Cache` above) are both this client's own state,
309
- * so their "position" is just a plain offset into each. */
310
- interface CompositeCursor {
311
- tier1Cursor?: string;
312
- tier2Offset: number;
313
- tier3Offset: number;
314
- /** The `Tier3Cache` entry the first page was sliced from - later pages keep slicing the same one. */
315
- tier3Key: string;
316
- fingerprint: string;
317
- }
318
-
319
- /** `undefined` for a missing, corrupted, or foreign-query cursor - every caller already treats "no
320
- * cursor" as "start this tier from the beginning," so there's no separate error path needed here. */
321
- function decodeCursor(raw: CompositeCursor | undefined, fingerprint: string): CompositeCursor | undefined {
322
- return raw?.fingerprint === fingerprint ? raw : undefined;
323
- }
324
-
325
- /** Resolves every hit's `entityUid` to a full `Message` via `getMessage()`, reusing `cache` across
326
- * repeated calls within the same search pass - `InboxContent`'s own search orchestration below re-merges
327
- * and re-resolves the *entire* current hit set each time a tier resolves, so without this cache every
328
- * stage would re-fetch messages an earlier stage already fetched. A hit whose message no longer resolves
329
- * (deleted after being indexed, or the fetch itself failed) is cached as `null` and dropped - the same
330
- * "a search hit can briefly outlive the message it points to" tolerance this function's inline
331
- * predecessor already had. */
332
- async function resolveHitsToMessages(
333
- hits: SearchResult[],
334
- cache: Map<string, Message | null>,
335
- ): Promise<{ messages: Message[]; snippets: Record<string, string> }> {
336
- const toFetch = hits.filter((hit) => !cache.has(hit.entityUid));
337
- await Promise.all(
338
- toFetch.map(async (hit) => {
339
- const message = await getMessage(hit.entityUid).catch(() => null);
340
- cache.set(hit.entityUid, message);
341
- }),
342
- );
343
- const messages: Message[] = [];
344
- const snippets: Record<string, string> = {};
345
- for (const hit of hits) {
346
- const message = cache.get(hit.entityUid);
347
- if (!message) {
348
- continue;
349
- }
350
- messages.push(message);
351
- if (hit.snippet) {
352
- snippets[message.uid] = hit.snippet;
353
- }
354
- }
355
- return { messages, snippets };
356
- }
357
-
358
- /** How far outside the scroll container's visible area the load-more sentinel still counts as "in view". */
359
- const LOAD_MORE_ROOT_MARGIN_PX = 200;
360
-
361
- /** How many full pages in a row that added no new rows the list keeps loading on its own before it shows a
362
- * "Load more" button instead. */
363
- const MAX_EMPTY_PAGE_CONTINUATIONS = 3;
364
-
365
- /** `true` when `more` has at least one row `shown` doesn't - i.e. appending it actually adds rows. Generic
366
- * over the row's own identity so both the message list (`uid`) and the conversation list
367
- * (`conversationId`) page the same way. */
368
- function hasUnseenRows<T>(shown: T[], more: T[], idOf: (row: T) => string): boolean {
369
- const seen = new Set(shown.map(idOf));
370
- return more.some((row) => !seen.has(idOf(row)));
371
- }
372
-
373
- /** The load-more sentinel's actual current geometry against its scroll container, with the same margin the
374
- * IntersectionObserver uses. */
375
- function isWithinLoadMoreRange(sentinel: HTMLElement, root: HTMLElement): boolean {
376
- const rect = sentinel.getBoundingClientRect();
377
- const rootRect = root.getBoundingClientRect();
378
- return rect.top <= rootRect.bottom + LOAD_MORE_ROOT_MARGIN_PX && rect.bottom >= rootRect.top - LOAD_MORE_ROOT_MARGIN_PX;
379
- }
380
-
381
- /** Appends `more` to `shown`, skipping any row already shown - a later page can repeat rows (Tier 1 and
382
- * Tier 2/3 cursors advance independently, so the same message can come back from a different tier on a
383
- * later page; a plain folder listing's pages shift when new mail arrives between fetches). Generic for the
384
- * same reason `hasUnseenRows()` is. */
385
- function appendUnseenRows<T>(shown: T[], more: T[], idOf: (row: T) => string): T[] {
386
- const seen = new Set(shown.map(idOf));
387
- const unseen: T[] = [];
388
- for (const row of more) {
389
- if (!seen.has(idOf(row))) {
390
- seen.add(idOf(row));
391
- unseen.push(row);
392
- }
393
- }
394
- return unseen.length === 0 ? shown : [...shown, ...unseen];
395
- }
396
-
397
- const messageUid = (message: Message) => message.uid;
398
- const conversationKey = (conversation: ConversationSummary) => conversation.conversationId;
399
-
400
- /** Flattens and sorts a per-mailbox fetch into one merged, newest-first list - the aggregate ("All
401
- * Inboxes" etc.) equivalent of `mergeSearchResults()` above, but simpler: an aggregated message has no
402
- * natural relevance score to normalize, so this only ever sorts by `receivedDate`. */
403
- function mergeInboxMessages(perMailbox: { mailbox: Mailbox; messages: Message[] }[]): Message[] {
404
- return perMailbox
405
- .flatMap((entry) => entry.messages)
406
- .sort((a, b) => new Date(b.receivedDate).getTime() - new Date(a.receivedDate).getTime());
407
- }
408
-
409
- /**
410
- * Fans out one `listMessages()` call per accessible mailbox that has a folder of `type`, merges the
411
- * results newest-first. A mailbox with no matching folder, or whose fetch fails, simply contributes
412
- * nothing - one mailbox's absence/failure must not blank out every other mailbox's messages.
413
- *
414
- * **Pagination scope trim (deliberate, matching this file's own documented Tier 2/3 tradeoffs)**: there is
415
- * no composite cursor across an arbitrary number of independently-paginated mailboxes in this pass - this
416
- * always fetches exactly each mailbox's own first page (`MESSAGE_PAGE_SIZE`) and the caller never offers a
417
- * "load more" for the result (see `InboxContent`'s own `hasMore` handling in aggregate mode) - a real
418
- * composite-cursor "load more" per mailbox is a natural v2 if usage shows people scrolling past the first
419
- * page in aggregate view often.
420
- */
421
- async function fetchAggregateMessages(
422
- mailboxFolders: MailboxFolders[],
423
- type: AggregateFolderType,
424
- filter: MessageListFilter,
425
- ): Promise<Message[]> {
426
- const perMailbox = await Promise.all(
427
- mailboxFolders.map(async ({ mailbox, folders }) => {
428
- const folder = folders.find((f) => f.type === type);
429
- if (!folder) {
430
- return { mailbox, messages: [] as Message[] };
431
- }
432
- // The filter is a server-side one per mailbox; the *sort* deliberately isn't offered here (see
433
- // this function's own pagination scope trim) - each mailbox contributes its own newest page and
434
- // they're merged newest-first, which a different sort key couldn't be made honest across an
435
- // arbitrary number of independently-paged folders.
436
- const messages = await listMessages(folder.uid, { limit: MESSAGE_PAGE_SIZE, filter }).catch(() => [] as Message[]);
437
- return { mailbox, messages };
438
- }),
439
- );
440
- return mergeInboxMessages(perMailbox);
441
- }
442
-
443
- export default function InboxPage(props: MailShellProps) {
444
- return (
445
- <MailShell {...props}>
446
- <InboxContent userUid={props.userUid} />
447
- </MailShell>
448
- );
449
- }
450
-
451
- function InboxContent({ userUid }: { userUid?: string }) {
452
- const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated, live } = useMailShell();
453
- const isMobile = useIsMobile();
454
- const { requestUnlock } = useUnlockPrompt();
455
- const [messages, setMessages] = useState<Message[]>([]);
456
- const [conversations, setConversations] = useState<ConversationSummary[]>([]);
457
- const [loading, setLoading] = useState(true);
458
- const [loadingMore, setLoadingMore] = useState(false);
459
- const [hasMore, setHasMore] = useState(false);
460
- const [error, setError] = useState<string | null>(null);
461
- const [selectedUid, setSelectedUid] = useState<string | null>(null);
462
- // The thread the conversation list opened, and which of its messages was picked - the reading pane
463
- // shows the whole conversation, positioned at that message (see `ConversationThreadPane`).
464
- const [openThread, setOpenThread] = useState<{ conversation: ConversationSummary; uid: string } | null>(null);
465
- // Messages the page has a newer copy of than `ConversationList` fetched (so far only the one the reading
466
- // pane just marked read), applied over its own child rows so they don't stay bold after being read.
467
- const [conversationPatches, setConversationPatches] = useState<Record<string, Message>>({});
468
- // Folders this session created on demand for a bulk Delete/Report junk - see `resolveFolderOfType()`.
469
- const lazyFoldersRef = useRef<Map<string, string>>(new Map());
470
- const [selectMode, setSelectMode] = useState(false);
471
- const [selectedUids, setSelectedUids] = useState<Set<string>>(new Set());
472
- // Select mode over the *conversation* list ticks whole conversations rather than messages: the rows
473
- // there are conversations, and a bulk action on one means "every message of it that is in this folder".
474
- // Their messages are fetched (once, cached here) as each conversation is ticked, so the selection bar
475
- // and every bulk action below keep working on the `Message[]` they already take.
476
- const [selectedConversationIds, setSelectedConversationIds] = useState<Set<string>>(new Set());
477
- const [conversationMessagesById, setConversationMessagesById] = useState<Record<string, Message[]>>({});
478
- // A ticked conversation whose messages are still being fetched - every bulk action is held meanwhile,
479
- // or it would act on a selection that is still arriving.
480
- const [resolvingSelection, setResolvingSelection] = useState(0);
481
- const [bulkBusy, setBulkBusy] = useState(false);
482
- const [bulkError, setBulkError] = useState<string | null>(null);
483
- // Bumped to force the list effect below to re-run - a bulk update is deliberately neither atomic nor
484
- // all-or-nothing (see `bulkUpdateMessages()`), so a rejection means refetching rather than guessing
485
- // which half of the selection actually landed.
486
- const [refreshKey, setRefreshKey] = useState(0);
487
- const [searchInput, setSearchInput] = useState("");
488
- const [searchQuery, setSearchQuery] = useState("");
489
- const [snippets, setSnippets] = useState<Record<string, string>>({});
490
- // The *open mailbox's* labels, for the Filter menu's Labels submenu, select mode's Apply label, and -
491
- // unless the selected message belongs to another mailbox - the reading pane's own Labels menu.
492
- const [mailboxLabels, setMailboxLabels] = useState<Label[]>([]);
493
- // The selected message's own mailbox's labels, when that is a different mailbox from the open one: in
494
- // search and aggregate views it can be, and its labels must not be offered as a filter for this one.
495
- // Empty (and never fetched) otherwise - see `labels` below.
496
- const [otherMailboxLabels, setOtherMailboxLabels] = useState<Label[]>([]);
497
- // Tier 2's own reported window coverage for the current search - undefined outside a search, or
498
- // before Tier 2 has resolved yet for this search pass.
499
- const [coverage, setCoverage] = useState<Coverage | undefined>(undefined);
500
- // Keyed by message uid - see decryptEncryptedRows(). Never cleared on folder/search switches (a
501
- // decrypted row stays decrypted while its keys stay unlocked; re-decrypting on every navigation would
502
- // waste work for no benefit) - only cleared when the mailbox's keys are locked (see the
503
- // `subscribeKeySession()` effect below).
504
- const [decryptedRows, setDecryptedRows] = useState<Record<string, DecryptedRow>>({});
505
- // Bumped after a successful on-demand unlock to re-run the search effect below - it's not a
506
- // dependency the effect could otherwise react to (getUnlockedKeys() is a plain module-level read, not
507
- // React state; see keySession.ts's own doc comment).
508
- const [unlockRefresh, setUnlockRefresh] = useState(0);
509
- // §_Progressive Results_: which of the current search's uids are still an unconfirmed Tier 1
510
- // `metadataOnly` guess (rendered as a skeleton row - see the JSX below) - empty outside a search, and
511
- // always empty again once every tier has reported for the current pass (each unresolved entry is by
512
- // then either confirmed, real content, or pruned - see the search orchestration effect).
513
- const [pendingUids, setPendingUids] = useState<Set<string>>(new Set());
514
- // Withholds a hard result count until every tier has reported for the current search pass (§_Progressive
515
- // Results_: "Never show a hard count until every tier has reported... A settled count is the signal
516
- // that ordering is final"). Reset on every fresh search; irrelevant outside search mode.
517
- const [tier1Done, setTier1Done] = useState(false);
518
- const [tier2Done, setTier2Done] = useState(false);
519
- const [tier3Done, setTier3Done] = useState(false);
520
- // Toggled by the "Search all mail" action next to the results count - removes Tier 3's own default
521
- // bound (tightened to Tier 2's coverage window otherwise - see tightenBeforeToCoverage()) for one
522
- // re-run. Stored as the mailbox/folder/query it was requested for, not a plain flag, so it resets the
523
- // moment any of those change - derived in the same render, so the search effect never runs a new
524
- // query with a previous query's unbounded Tier 3 window first.
525
- const [searchAllMailKey, setSearchAllMailKey] = useState<string | null>(null);
526
- const searchAllMailScope = `${mailboxUid ?? ""}\n${folderUid ?? ""}\n${searchQuery}`;
527
- const searchAllMail = searchAllMailKey === searchAllMailScope;
528
- // The mailbox unlock/decrypt call sites below treat as "the" mailbox when there's no single selected
529
- // one (aggregate mode) - mirrors `MailShell`'s own identical `defaultMailboxUid` fallback. An
530
- // aggregate-view row from a *different*, not-yet-unlocked mailbox stays locked until that mailbox's
531
- // own folder view is opened directly - an accepted limitation, not a bug (see `MailShell`'s own doc
532
- // comment on the same tradeoff for its `LocalIndexLifecycle`/`KeyEnrollmentGate` wiring).
533
- const activeMailboxUid = mailboxUid ?? mailboxes.find((mb) => mb.ownerUserUid === userUid)?.uid ?? mailboxes[0]?.uid;
534
- const mailboxKeys = mailboxes.find((mb) => mb.uid === activeMailboxUid)?.keys ?? [];
535
- // The Sort/Filter menus' and "Show as conversations"' current settings, remembered per mailbox across
536
- // reloads (`listPreferences.ts`). Read during render, not in an effect, so the very first listing
537
- // already uses the remembered arrangement rather than fetching the default one and immediately
538
- // refetching it - and kept per mailbox rather than as one value plus a "which mailbox is this?" check,
539
- // so switching mailbox simply reads the other entry. What this session has changed wins over the store,
540
- // which a storage-blocked browser refuses to keep.
541
- const [preferencesByMailbox, setPreferencesByMailbox] = useState<Record<string, MailListPreferences>>({});
542
- const preferences: MailListPreferences = preferencesByMailbox[activeMailboxUid] ?? getMailListPreferences(activeMailboxUid);
543
- function updatePreferences(patch: Partial<MailListPreferences>) {
544
- const next = { ...preferences, ...patch };
545
- setPreferencesByMailbox((prev) => ({ ...prev, [activeMailboxUid]: next }));
546
- setMailListPreferences(activeMailboxUid, next);
547
- }
548
- // Search stays real-folder-only - Tier 1/2/3 are all deeply mailbox/folder-scoped, and extending them
549
- // to span an arbitrary number of mailboxes is out of scope for this pass (see the aggregate-fetch
550
- // branch below, which the search effect never reaches while `folderUid` is unset).
551
- const isSearching = !preferences.showAsConversations && searchQuery.length > 0 && !aggregateFolderType;
552
- // Focused/Other is an Inbox-only concept (`FocusedInboxUtils.classifyMessage()` short-circuits to
553
- // Focused for every other folder), and
554
- // search results are ranked across folders rather than listed from one, so neither tab is offered
555
- // there. Not offered for an aggregate view either (each mailbox classifies independently; merging
556
- // that is out of scope).
557
- const currentFolders = mailboxFolders.find((mf) => mf.mailbox.uid === activeMailboxUid)?.folders ?? [];
558
- const currentFolderIsInbox = currentFolders.find((f) => f.uid === folderUid)?.type === "inbox";
559
- const offerClassificationFilters = currentFolderIsInbox && !isSearching && !aggregateFolderType;
560
- // A remembered Focused/Other filter must not silently narrow a folder that has no Focused Inbox to
561
- // speak of - it stays remembered for when the Inbox is opened again, but doesn't apply meanwhile.
562
- const effectiveFilter: MessageListFilter =
563
- (preferences.filter === "focused" || preferences.filter === "other") && !offerClassificationFilters
564
- ? "all"
565
- : preferences.filter;
566
- // Left out entirely rather than sent empty, so a list with no label filter asks for exactly the URL it
567
- // always did.
568
- const labelFilter = preferences.labelUids.length > 0 ? { labelUids: preferences.labelUids } : {};
569
- // Every server-side list parameter the toolbar controls, in one place so the first page and each
570
- // `loadMore()` page can't drift apart.
571
- const listParams: MessageListParams = {
572
- limit: MESSAGE_PAGE_SIZE,
573
- sortBy: preferences.sortBy,
574
- sortOrder: preferences.sortOrder,
575
- filter: effectiveFilter,
576
- ...labelFilter,
577
- };
578
- // A dependency of the list effect and of `loadMore()`, which can't take the array itself (a new one
579
- // every render would refetch on every render).
580
- const labelFilterKey = preferences.labelUids.join(",");
581
- /** The sort the *server* is being asked for, as one dependency value. Empty while conversations are
582
- * shown: `GET /mail/messages/conversations` takes no sort parameters at all, so those rows are ordered
583
- * in the browser (`sortConversations()`) and rearranging them must not refetch the identical page -
584
- * which would also collapse whichever conversations the reader had expanded. */
585
- const serverSortKey = preferences.showAsConversations ? "" : `${preferences.sortBy}:${preferences.sortOrder}`;
586
- /** The conversation list's own equivalent of `listParams` - a function because the page differs. */
587
- function conversationParams(page: number): ConversationListParams {
588
- return { folderUid, filter: effectiveFilter, ...labelFilter, page, limit: MESSAGE_PAGE_SIZE };
589
- }
590
- // How far into the folder's *current* server-side listing the rows fetched so far reach. Offset
591
- // paging, not a cursor (`listMessages()` has none): a row removed locally (archived, scheduled send
592
- // cancelled) also left the folder server-side, shifting every later message back by one - so each
593
- // removal steps this back too, or the next page would silently skip a message. See `loadMore()`.
594
- const listedOffsetRef = useRef(0);
595
- const scrollContainerRef = useRef<HTMLDivElement | null>(null);
596
- // State (via a callback ref), not a plain ref: the sentinel mounts and unmounts as the list loads,
597
- // filters, and empties, and the observer effect below must re-attach to whichever node is current.
598
- const [sentinel, setSentinel] = useState<HTMLDivElement | null>(null);
599
- const [loadMoreError, setLoadMoreError] = useState<string | null>(null);
600
- const loadMoreErrorRef = useRef<string | null>(null);
601
- loadMoreErrorRef.current = loadMoreError;
602
- const loadMoreInFlightRef = useRef(false);
603
- // Bumped by `loadMore()` for each page that added at least one row - the only event the continuation
604
- // effect below keeps loading after. A counter rather than watching `loadingMore` flip back to false: a
605
- // fast response can settle before React ever renders the `true`, so that flip isn't reliably observable.
606
- const [appendedPageCount, setAppendedPageCount] = useState(0);
607
- // Consecutive full pages that added no new rows (every row was already shown - e.g. new mail shifted the
608
- // folder's pages). Such a page still counts as progress for the continuation effect, up to
609
- // `MAX_EMPTY_PAGE_CONTINUATIONS` in a row; past that, `loadMoreStalled` shows a "Load more" button
610
- // instead, so a server that keeps repeating rows is never looped on.
611
- const emptyPageStreakRef = useRef(0);
612
- const [loadMoreStalled, setLoadMoreStalled] = useState(false);
613
- const messagesRef = useRef(messages);
614
- messagesRef.current = messages;
615
- const conversationsRef = useRef(conversations);
616
- conversationsRef.current = conversations;
617
- // Reset to a fresh Map at the start of every new search pass (see the search effect below) - see
618
- // resolveHitsToMessages()'s own doc comment on why this needs to persist *within* one pass but not
619
- // across passes (a stale `null` for a uid that's since become resolvable elsewhere must not stick).
620
- const resolvedMessageCacheRef = useRef<Map<string, Message | null>>(new Map());
621
- // Session-scoped, never explicitly cleared - see Tier3Cache's own doc comment above.
622
- const tier3CacheRef = useRef<Tier3Cache>(new Map());
623
- // The latest composite cursor this search pass has reached - read by loadMore(), written at the end
624
- // of both the fresh-search orchestration and loadMore() itself. Not React state: it never drives a
625
- // render on its own, only what loadMore() does with it later.
626
- const compositeCursorRef = useRef<CompositeCursor | undefined>(undefined);
627
- // Guards every async load below - each search stage, the plain folder/conversation/aggregate listings,
628
- // and `loadMore()` - against a stale, still-in-flight pass clobbering state for a newer one that started
629
- // after it (the query, folder, or view changed) - the same `loadSeq`-style monotonic-id pattern already used elsewhere in this codebase (e.g.
630
- // `settings/privacy/index.tsx`'s `ExportSection`), generalized here across three independently-timed
631
- // async stages instead of one.
632
- const searchRunIdRef = useRef(0);
633
- // Bumped whenever `activeMailboxUid`'s keys are locked (see the `subscribeKeySession()` effect below) - a
634
- // decrypt that started before the lock checks it before storing its now-stale plaintext rows.
635
- const lockGenerationRef = useRef(0);
636
- // `messages` themselves aren't a dependency here on purpose - a message uid, once decrypted, is
637
- // never re-decrypted just because the list re-renders with the same rows (e.g. a folder-unrelated
638
- // state update elsewhere). New rows (a fresh page load, load-more, or a completed search) each
639
- // re-trigger this the normal way, by changing `messages` itself.
640
- //
641
- // Scoped to `activeMailboxUid`'s own rows only - in aggregate mode `messages` can span several
642
- // mailboxes, but only one mailbox's keys are ever being unlocked/tracked here (see `activeMailboxUid`'s
643
- // own doc comment above); an encrypted row from any other mailbox simply isn't a candidate for this
644
- // auto-decrypt or the manual unlock banner below.
645
- const undecryptedEncryptedUids = messages
646
- .filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER && !decryptedRows[m.uid] && m.mailboxUid === activeMailboxUid)
647
- .map((m) => m.uid);
648
-
649
- // Once unlocked, silently decrypt this page's own encrypted rows to show their real subject/preview -
650
- // no prompt needed here, the same way searchEncryptedCandidates() already auto-includes decrypted
651
- // matches once unlocked without asking again. Only the *first* unlock (or a fresh page of messages
652
- // arriving) needs this; `handleUnlockList()` below covers the not-yet-unlocked case explicitly.
653
- useEffect(() => {
654
- if (undecryptedEncryptedUids.length === 0 || !activeMailboxUid) {
655
- return;
656
- }
657
- const unlocked = getUnlockedKeys(activeMailboxUid);
658
- if (!unlocked) {
659
- return;
660
- }
661
- let cancelled = false;
662
- const lockGeneration = lockGenerationRef.current;
663
- void decryptEncryptedRows(
664
- messages.filter((m) => undecryptedEncryptedUids.includes(m.uid)),
665
- unlocked,
666
- ).then((decrypted) => {
667
- // A lock while this was in flight already cleared `decryptedRows` - never put them back.
668
- if (!cancelled && lockGeneration === lockGenerationRef.current && Object.keys(decrypted).length > 0) {
669
- setDecryptedRows((prev) => ({ ...prev, ...decrypted }));
670
- }
671
- });
672
- return () => {
673
- cancelled = true;
674
- };
675
- }, [messages, unlockRefresh]);
676
-
677
- // Locking a mailbox's keys (idle timeout, "Destroy keys on this device now", sign-out) must take its
678
- // decrypted content off screen too, not just out of memory: decrypted subjects/previews, search
679
- // snippets (Tier 2/3 snippets are decrypted content), and the already-decrypted Tier 3 candidates. A
680
- // live search re-runs, so Tier 2/3 contribute nothing again until the user unlocks. Read through a ref
681
- // so the subscription itself doesn't churn on every render.
682
- const keyLockStateRef = useRef({ activeMailboxUid, isSearching });
683
- keyLockStateRef.current = { activeMailboxUid, isSearching };
684
- useEffect(
685
- () =>
686
- subscribeKeySession(({ mailboxUid: changedMailboxUid, state }) => {
687
- const current = keyLockStateRef.current;
688
- if (state !== "locked" || changedMailboxUid !== current.activeMailboxUid) {
689
- return;
690
- }
691
- lockGenerationRef.current += 1;
692
- setDecryptedRows({});
693
- setSnippets({});
694
- tier3CacheRef.current.clear();
695
- if (current.isSearching) {
696
- setUnlockRefresh((n) => n + 1);
697
- }
698
- }),
699
- [],
700
- );
701
-
702
- async function handleUnlockList() {
703
- try {
704
- const unlocked = await requestUnlock(activeMailboxUid, mailboxKeys);
705
- const lockGeneration = lockGenerationRef.current;
706
- const decrypted = await decryptEncryptedRows(
707
- messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER && m.mailboxUid === activeMailboxUid),
708
- unlocked,
709
- );
710
- if (lockGeneration === lockGenerationRef.current) {
711
- setDecryptedRows((prev) => ({ ...prev, ...decrypted }));
712
- }
713
- } catch {
714
- // User dismissed the unlock dialog - rows stay exactly as they were.
715
- }
716
- }
717
-
718
- async function handleUnlockSearch() {
719
- try {
720
- await requestUnlock(activeMailboxUid, mailboxKeys);
721
- setUnlockRefresh((n) => n + 1);
722
- } catch {
723
- // User dismissed the unlock dialog - the search results stay exactly as they were.
724
- }
725
- }
726
-
727
- // Labels are mailbox-wide, not folder-scoped - fetched once per mailbox rather than per message, and
728
- // handed to the detail pane below. A failure here just means the Labels control stays hidden (an empty
729
- // `labels` array) rather than blocking the rest of the inbox. Keyed on the *selected message's own*
730
- // mailbox: aggregate views and search results can show messages from a mailbox other than
731
- // `activeMailboxUid`, and offering that mailbox's labels would let a label from the wrong mailbox be
732
- // applied. The conversation view stays scoped to `activeMailboxUid`, the mailbox its threads come from.
733
- const selectedMessageMailboxUid = messages.find((m) => m.uid === selectedUid)?.mailboxUid;
734
- const labelsMailboxUid = preferences.showAsConversations ? activeMailboxUid : (selectedMessageMailboxUid ?? activeMailboxUid);
735
- // Almost always the open mailbox's own labels, already fetched below - so `labels` reuses that list
736
- // rather than asking for the identical one a second time (which is what this page did on every single
737
- // view). Only a selected message from *another* mailbox - a search hit or an aggregate-view row - needs
738
- // its own fetch, and only then is this state used at all.
739
- const otherMailbox = labelsMailboxUid !== activeMailboxUid;
740
- const labels = otherMailbox ? otherMailboxLabels : mailboxLabels;
741
- // `labelsMailboxUid` is always set: `MailShell` only renders this component once at least one mailbox
742
- // exists, so `activeMailboxUid` (its fallback) always resolves.
743
- useEffect(() => {
744
- if (!otherMailbox) {
745
- setOtherMailboxLabels([]);
746
- return;
747
- }
748
- let cancelled = false;
749
- setOtherMailboxLabels([]);
750
- listLabels(labelsMailboxUid, { limit: 200 })
751
- .then((result) => {
752
- if (!cancelled) {
753
- setOtherMailboxLabels(result);
754
- }
755
- })
756
- .catch(() => undefined);
757
- return () => {
758
- cancelled = true;
759
- };
760
- }, [labelsMailboxUid, otherMailbox]);
761
-
762
- useEffect(() => {
763
- let cancelled = false;
764
- setMailboxLabels([]);
765
- listLabels(activeMailboxUid, { limit: 200 })
766
- .then((result) => {
767
- if (!cancelled) {
768
- setMailboxLabels(result);
769
- }
770
- })
771
- .catch(() => undefined);
772
- return () => {
773
- cancelled = true;
774
- };
775
- }, [activeMailboxUid]);
776
-
777
- // Debounce the raw input into the query actually searched, so every keystroke doesn't fire a request.
778
- useEffect(() => {
779
- const handle = setTimeout(() => setSearchQuery(searchInput.trim()), SEARCH_DEBOUNCE_MS);
780
- return () => clearTimeout(handle);
781
- }, [searchInput]);
782
-
783
- // Loads whichever list the current folder, arrangement and search state call for, and resets every
784
- // piece of per-listing state (selection, paging, select mode) that a previous listing left behind.
785
- useEffect(() => {
786
- setSelectedUid(null);
787
- setOpenThread(null);
788
- setConversationPatches({});
789
- setSelectedUids(new Set());
790
- setSelectedConversationIds(new Set());
791
- setConversationMessagesById({});
792
- listedOffsetRef.current = 0;
793
- compositeCursorRef.current = undefined;
794
- setHasMore(false);
795
- setLoadMoreError(null);
796
- setLoadMoreStalled(false);
797
- emptyPageStreakRef.current = 0;
798
- // Every run - search or not - supersedes whatever an earlier run (or a `loadMore()` it started)
799
- // still has in flight; each async callback below checks this before touching state.
800
- searchRunIdRef.current += 1;
801
- const myRunId = searchRunIdRef.current;
802
- const isCurrentRun = () => searchRunIdRef.current === myRunId;
803
- // Also invalidates on unmount, so nothing lands after the component is gone. Unconditional: this
804
- // cleanup only ever runs for the latest run of this effect (React runs it before the next run
805
- // bumps the id, or on unmount), so the id is always still `myRunId` here.
806
- const invalidate = () => {
807
- searchRunIdRef.current += 1;
808
- };
809
-
810
- if (!folderUid && !aggregateFolderType) {
811
- // The shell hasn't resolved this mailbox's folders yet (the render below says so). Listing
812
- // anything now means a request whose answer is thrown away the moment the Inbox arrives - and
813
- // for conversations a mailbox-wide grouping pass, the most expensive listing there is.
814
- setMessages([]);
815
- setConversations([]);
816
- setLoading(false);
817
- return;
818
- }
819
- if (aggregateFolderType && mailboxFolders.length === 0) {
820
- // The same, for a merged view: it has no folder of its own, but it is built from every
821
- // mailbox's folders, and this effect re-runs the moment they arrive. Listing twice is what
822
- // that cost before.
823
- setMessages([]);
824
- setConversations([]);
825
- setLoading(true);
826
- return;
827
- }
828
-
829
- if (preferences.showAsConversations) {
830
- // Conversations stay single-mailbox (not aggregated across mailboxes in this pass) - in
831
- // aggregate mode this falls back to `activeMailboxUid`, the same mailbox unlock/labels use
832
- // (always set - `MailShell` only renders this component once at least one mailbox exists).
833
- // They're scoped to the selected folder (`folderUid`, absent only in aggregate mode), so the
834
- // conversation list matches the folder the sidebar has selected rather than the whole mailbox.
835
- setLoading(true);
836
- setError(null);
837
- listConversations(activeMailboxUid, conversationParams(0))
838
- .then((result) => {
839
- if (isCurrentRun()) {
840
- setConversations(result);
841
- listedOffsetRef.current = result.length;
842
- setHasMore(result.length === MESSAGE_PAGE_SIZE);
843
- }
844
- })
845
- .catch((err) => {
846
- if (isCurrentRun()) {
847
- setError(err instanceof ApiRequestError ? err.message : "Could not load conversations.");
848
- }
849
- })
850
- .finally(() => {
851
- if (isCurrentRun()) {
852
- setLoading(false);
853
- }
854
- });
855
- return invalidate;
856
- }
857
-
858
- if (aggregateFolderType) {
859
- setLoading(true);
860
- setError(null);
861
- // hasMore stays false (set above) - see fetchAggregateMessages()'s own pagination scope trim.
862
- void fetchAggregateMessages(mailboxFolders, aggregateFolderType, effectiveFilter)
863
- .then((results) => {
864
- if (isCurrentRun()) {
865
- setMessages(results);
866
- }
867
- })
868
- .finally(() => {
869
- if (isCurrentRun()) {
870
- setLoading(false);
871
- }
872
- });
873
- return invalidate;
874
- }
875
-
876
- setLoading(true);
877
- setError(null);
878
-
879
- if (isSearching) {
880
- setCoverage(undefined);
881
- setPendingUids(new Set());
882
- setTier1Done(false);
883
- setTier2Done(false);
884
- setTier3Done(false);
885
- resolvedMessageCacheRef.current = new Map();
886
- const parsed = parseSearchQuery(searchQuery);
887
- const unlocked = getUnlockedKeys(mailboxUid!);
888
- const fingerprint = queryFingerprint(parsed);
889
-
890
- // Recomputes and re-renders the merged list from whatever tiers have reported so far -
891
- // called once after Tier 1+2 (interim: unconfirmed Tier 1 metadataOnly hits render as
892
- // skeletons) and again after Tier 3 (final: anything still unconfirmed is pruned instead -
893
- // §_Progressive Results_' "Skeletons resolve or disappear"). Bails out via `myRunId` if a
894
- // newer search pass has since started.
895
- async function reveal(tier1Hits: SearchResult[], tier2Hits: SearchResult[], tier3Hits: SearchResult[], final: boolean) {
896
- const capped = capSkeletons(tier1Hits);
897
- const confirmed = new Set([...tier2Hits, ...tier3Hits].map((r) => r.entityUid));
898
- const effectiveTier1 = final ? capped.filter((hit) => !hit.metadataOnly || confirmed.has(hit.entityUid)) : capped;
899
- const merged = mergeSearchResults(effectiveTier1, tier2Hits, tier3Hits);
900
- const { messages: resolved, snippets: resolvedSnippets } = await resolveHitsToMessages(
901
- merged,
902
- resolvedMessageCacheRef.current,
903
- );
904
- if (searchRunIdRef.current !== myRunId) {
905
- return;
906
- }
907
- setMessages(resolved);
908
- setSnippets(resolvedSnippets);
909
- if (final) {
910
- setPendingUids(new Set());
911
- } else {
912
- const pending = new Set<string>();
913
- for (const hit of capped) {
914
- if (hit.metadataOnly && !confirmed.has(hit.entityUid)) {
915
- pending.add(hit.entityUid);
916
- }
917
- }
918
- setPendingUids(pending);
919
- }
920
- }
921
-
922
- void (async () => {
923
- try {
924
- const [tier1Page, tier2Page] = await Promise.all([
925
- searchMailbox(parsed.text, tier1SearchParams(parsed, undefined, mailboxUid!)),
926
- searchLocalIndex(mailboxUid!, parsed, unlocked, MESSAGE_PAGE_SIZE, 0),
927
- ]);
928
- if (searchRunIdRef.current !== myRunId) {
929
- return;
930
- }
931
- setTier1Done(true);
932
- setTier2Done(true);
933
- setCoverage(tier2Page.coverage);
934
- setLoading(false);
935
- await reveal(tier1Page.results, tier2Page.results, [], false);
936
-
937
- const windows = tier3Windows(parsed, tier2Page.coverage, searchAllMail);
938
- const cacheKey = tier3CacheKey(mailboxUid!, windows, !!unlocked);
939
- let tier3Full = tier3CacheRef.current.get(cacheKey);
940
- if (!tier3Full) {
941
- tier3Full = await searchTier3Windows(windows, unlocked, mailboxUid!);
942
- if (searchRunIdRef.current !== myRunId) {
943
- return;
944
- }
945
- tier3CacheRef.current.set(cacheKey, tier3Full);
946
- }
947
- const tier3Page = tier3Full.slice(0, MESSAGE_PAGE_SIZE);
948
- setTier3Done(true);
949
- compositeCursorRef.current = {
950
- tier1Cursor: tier1Page.nextCursor,
951
- tier2Offset: tier2Page.results.length,
952
- tier3Offset: tier3Page.length,
953
- tier3Key: cacheKey,
954
- fingerprint,
955
- };
956
- setHasMore(!!tier1Page.nextCursor || tier2Page.hasMore || tier3Page.length < tier3Full.length);
957
- await reveal(tier1Page.results, tier2Page.results, tier3Page, true);
958
- } catch (err) {
959
- if (searchRunIdRef.current === myRunId) {
960
- setError(err instanceof ApiRequestError ? err.message : "Search failed.");
961
- setLoading(false);
962
- }
963
- }
964
- })();
965
- return invalidate;
966
- }
967
-
968
- // Set: the guard at the top of this effect returned for a view with neither a folder nor an
969
- // aggregate type, and the aggregate branch above returned for the one with an aggregate type.
970
- listMessages(folderUid!, listParams)
971
- .then((results) => {
972
- if (isCurrentRun()) {
973
- setMessages(results);
974
- listedOffsetRef.current = results.length;
975
- setHasMore(results.length === MESSAGE_PAGE_SIZE);
976
- }
977
- })
978
- .catch((err) => {
979
- if (isCurrentRun()) {
980
- setError(err instanceof ApiRequestError ? err.message : "Could not load messages.");
981
- }
982
- })
983
- .finally(() => {
984
- if (isCurrentRun()) {
985
- setLoading(false);
986
- }
987
- });
988
- return invalidate;
989
- // `unlockRefresh`/`searchAllMail` are dependencies solely so `handleUnlockSearch()`/"Search all
990
- // mail" can force this effect to re-run the search above - Tier 3 (encrypted) results silently
991
- // contribute nothing without unlocked keys, so this is what actually makes them appear once the
992
- // user unlocks, and what makes "Search all mail" actually remove Tier 3's coverage bound. Neither
993
- // has any effect on the non-search branch below; re-running it with identical inputs just
994
- // re-fetches the same page.
995
- // `refreshKey` is a dependency for the same reason: a failed bulk action bumps it to refetch,
996
- // since a bulk update applies until its first rejection rather than all-or-nothing.
997
- }, [
998
- preferences.showAsConversations,
999
- serverSortKey,
1000
- effectiveFilter,
1001
- labelFilterKey,
1002
- refreshKey,
1003
- folderUid,
1004
- mailboxUid,
1005
- isSearching,
1006
- searchQuery,
1007
- unlockRefresh,
1008
- searchAllMail,
1009
- aggregateFolderType,
1010
- mailboxFolders,
1011
- activeMailboxUid,
1012
- ]);
1013
-
1014
- // New mail without a reload. `live` is bumped by a push event, a reconnect, the safety-net poll or the tab coming back (see
1015
- // `useMailLiveUpdates()`); this then quietly refetches the first page of whatever is listed and folds it in - unlike the
1016
- // effect above it resets nothing: not the selection, the open thread, select mode, the scroll position or the rows
1017
- // already paged in, and nothing it fetches is marked read. It stands aside for a search (whose rows aren't a folder's),
1018
- // a listing still loading and a "load more" in flight, and for events about folders the list isn't showing - a
1019
- // conversation list, which can span folders, refreshes for any. Any failure is silent: the next tick tries again.
1020
- const liveRunRef = useRef(0);
1021
- useEffect(() => {
1022
- if (live.tick === 0 || isSearching || loading || loadMoreInFlightRef.current || (!folderUid && !aggregateFolderType)) {
1023
- return;
1024
- }
1025
- if (!preferences.showAsConversations && live.folderUids) {
1026
- const shown = new Set(
1027
- aggregateFolderType
1028
- ? mailboxFolders.flatMap((entry) => entry.folders.filter((f) => f.type === aggregateFolderType).map((f) => f.uid))
1029
- : [folderUid!],
1030
- );
1031
- if (![...live.folderUids].some((uid) => shown.has(uid))) {
1032
- return;
1033
- }
1034
- }
1035
- // Superseded by a newer refresh, or by any reload of the list itself (which bumps the search run id).
1036
- const listRun = searchRunIdRef.current;
1037
- const myRun = ++liveRunRef.current;
1038
- const isCurrent = () => searchRunIdRef.current === listRun && liveRunRef.current === myRun;
1039
- void (async () => {
1040
- try {
1041
- if (preferences.showAsConversations) {
1042
- const fresh = await listConversations(activeMailboxUid, conversationParams(0));
1043
- if (!isCurrent()) {
1044
- return;
1045
- }
1046
- const merged = mergeFirstPage(conversationsRef.current, fresh, conversationKey, MESSAGE_PAGE_SIZE);
1047
- setConversations(merged.rows);
1048
- listedOffsetRef.current = merged.complete ? fresh.length : listedOffsetRef.current + merged.added;
1049
- setHasMore(!merged.complete);
1050
- } else if (aggregateFolderType) {
1051
- // No paging here (see `fetchAggregateMessages()`): the fresh merged first pages are the list.
1052
- const fresh = await fetchAggregateMessages(mailboxFolders, aggregateFolderType, effectiveFilter);
1053
- if (isCurrent()) {
1054
- setMessages(fresh);
1055
- }
1056
- } else {
1057
- const fresh = await listMessages(folderUid!, listParams);
1058
- if (!isCurrent()) {
1059
- return;
1060
- }
1061
- // A row the reader has just changed (a higher version) is not put back by a fetch that began before.
1062
- const merged = mergeFirstPage(messagesRef.current, fresh, messageUid, MESSAGE_PAGE_SIZE, (current, next) =>
1063
- current.version > next.version ? current : next,
1064
- );
1065
- setMessages(merged.rows);
1066
- listedOffsetRef.current = merged.complete ? fresh.length : listedOffsetRef.current + merged.added;
1067
- setHasMore(!merged.complete);
1068
- }
1069
- } catch {
1070
- // Quiet by design - see above.
1071
- }
1072
- })();
1073
- }, [live.tick]);
1074
-
1075
- function handleSearchAllMail() {
1076
- setSearchAllMailKey(searchAllMailScope);
1077
- }
1078
-
1079
- const loadMore = useCallback(async () => {
1080
- const parsed = parseSearchQuery(searchQuery);
1081
- const fingerprint = queryFingerprint(parsed);
1082
- // While searching, a load-more continues from this query's own cursor - absent until its first page
1083
- // has fully finished (the ref may still hold an earlier query's), in which case there's nowhere to
1084
- // continue from yet.
1085
- const searchCursor = decodeCursor(compositeCursorRef.current, fingerprint);
1086
- if (
1087
- loadMoreInFlightRef.current ||
1088
- !hasMore ||
1089
- loading ||
1090
- (!preferences.showAsConversations && !folderUid) ||
1091
- (isSearching && !searchCursor)
1092
- ) {
1093
- return;
1094
- }
1095
- // A ref, not `loadingMore` state: the observer and the continuation effect below can both call in the
1096
- // same tick, each closing over a render where `loadingMore` was still false.
1097
- loadMoreInFlightRef.current = true;
1098
- setLoadMoreError(null);
1099
- setLoadMoreStalled(false);
1100
- setLoadingMore(true);
1101
- /** Records whether a landed page added rows, and whether the continuation effect should keep going. */
1102
- const notePageLanded = (addedRows: boolean, moreRemain: boolean) => {
1103
- if (addedRows) {
1104
- emptyPageStreakRef.current = 0;
1105
- setAppendedPageCount((n) => n + 1);
1106
- } else if (moreRemain) {
1107
- emptyPageStreakRef.current += 1;
1108
- if (emptyPageStreakRef.current <= MAX_EMPTY_PAGE_CONTINUATIONS) {
1109
- setAppendedPageCount((n) => n + 1);
1110
- } else {
1111
- emptyPageStreakRef.current = 0;
1112
- setLoadMoreStalled(true);
1113
- }
1114
- }
1115
- };
1116
- // A query/folder/view change while this page is in flight bumps the run id (see the effect above) -
1117
- // its rows then belong to a list that's no longer on screen and must not be appended to the new one.
1118
- const myRunId = searchRunIdRef.current;
1119
- const isCurrentRun = () => searchRunIdRef.current === myRunId;
1120
- try {
1121
- if (preferences.showAsConversations) {
1122
- const page = Math.floor(listedOffsetRef.current / MESSAGE_PAGE_SIZE);
1123
- const more = await listConversations(activeMailboxUid, conversationParams(page));
1124
- if (!isCurrentRun()) {
1125
- return;
1126
- }
1127
- const addedRows = hasUnseenRows(conversationsRef.current, more, conversationKey);
1128
- setConversations((prev) => appendUnseenRows(prev, more, conversationKey));
1129
- setHasMore(more.length === MESSAGE_PAGE_SIZE);
1130
- listedOffsetRef.current = page * MESSAGE_PAGE_SIZE + more.length;
1131
- notePageLanded(addedRows, more.length === MESSAGE_PAGE_SIZE);
1132
- } else if (isSearching) {
1133
- const unlocked = getUnlockedKeys(mailboxUid!);
1134
- const cursor = searchCursor!;
1135
-
1136
- const [tier1Page, tier2Page] = await Promise.all([
1137
- searchMailbox(parsed.text, tier1SearchParams(parsed, cursor.tier1Cursor, mailboxUid!)),
1138
- searchLocalIndex(mailboxUid!, parsed, unlocked, MESSAGE_PAGE_SIZE, cursor.tier2Offset),
1139
- ]);
1140
- // The first page cached this pass under `tier3Key` before it created the cursor.
1141
- const tier3Full = tier3CacheRef.current.get(cursor.tier3Key)!;
1142
- const tier3Offset = cursor.tier3Offset;
1143
- const tier3Page = tier3Full.slice(tier3Offset, tier3Offset + MESSAGE_PAGE_SIZE);
1144
-
1145
- // Unlike the fresh-search pass above, a load-more page is resolved and appended in one
1146
- // shot rather than progressively revealed - rows already on screen shouldn't reorder or
1147
- // grow skeletons out from under a reader who has since scrolled past them. Any Tier 1
1148
- // metadataOnly hit this page that neither Tier 2 nor Tier 3 (both already awaited above)
1149
- // confirms simply keeps showing its existing placeholder text rather than a live skeleton
1150
- // - a deliberate, documented scope trim of progressive reveal to the first page only.
1151
- const merged = mergeSearchResults(capSkeletons(tier1Page.results), tier2Page.results, tier3Page);
1152
- const { messages: more, snippets: moreSnippets } = await resolveHitsToMessages(merged, resolvedMessageCacheRef.current);
1153
- if (!isCurrentRun()) {
1154
- return;
1155
- }
1156
- const addedRows = hasUnseenRows(messagesRef.current, more, messageUid);
1157
- setMessages((prev) => appendUnseenRows(prev, more, messageUid));
1158
- setSnippets((prev) => ({ ...prev, ...moreSnippets }));
1159
-
1160
- const nextTier3Offset = tier3Offset + tier3Page.length;
1161
- compositeCursorRef.current = {
1162
- tier1Cursor: tier1Page.nextCursor,
1163
- tier2Offset: cursor.tier2Offset + tier2Page.results.length,
1164
- tier3Offset: nextTier3Offset,
1165
- tier3Key: cursor.tier3Key,
1166
- fingerprint,
1167
- };
1168
- const moreRemain = !!tier1Page.nextCursor || tier2Page.hasMore || nextTier3Offset < tier3Full.length;
1169
- setHasMore(moreRemain);
1170
- notePageLanded(addedRows, moreRemain);
1171
- } else {
1172
- // The page containing the first message not fetched yet. After a local removal that offset is
1173
- // no longer a page boundary, so this page overlaps rows already shown - `appendUnseenRows()`
1174
- // drops those - rather than skipping the message that shifted back across the boundary.
1175
- const page = Math.floor(listedOffsetRef.current / MESSAGE_PAGE_SIZE);
1176
- const more = await listMessages(folderUid!, { ...listParams, page });
1177
- if (!isCurrentRun()) {
1178
- return;
1179
- }
1180
- const addedRows = hasUnseenRows(messagesRef.current, more, messageUid);
1181
- setMessages((prev) => appendUnseenRows(prev, more, messageUid));
1182
- setHasMore(more.length === MESSAGE_PAGE_SIZE);
1183
- listedOffsetRef.current = page * MESSAGE_PAGE_SIZE + more.length;
1184
- notePageLanded(addedRows, more.length === MESSAGE_PAGE_SIZE);
1185
- }
1186
- } catch (err) {
1187
- // Shown next to the sentinel with a Retry button - never auto-retried (see the continuation
1188
- // effect below), so a failing server isn't hammered while the sentinel stays in view.
1189
- if (isCurrentRun()) {
1190
- setLoadMoreError(err instanceof ApiRequestError ? err.message : "Could not load more messages.");
1191
- }
1192
- } finally {
1193
- loadMoreInFlightRef.current = false;
1194
- setLoadingMore(false);
1195
- }
1196
- }, [
1197
- hasMore,
1198
- loading,
1199
- preferences.showAsConversations,
1200
- serverSortKey,
1201
- effectiveFilter,
1202
- labelFilterKey,
1203
- folderUid,
1204
- isSearching,
1205
- searchQuery,
1206
- mailboxUid,
1207
- activeMailboxUid,
1208
- ]);
1209
-
1210
- // Always calls the latest `loadMore` closure so the effect below doesn't need `loadMore` itself in its
1211
- // dependency array (it changes on every keystroke/page load, which would otherwise mean nothing here).
1212
- const loadMoreRef = useRef(loadMore);
1213
- loadMoreRef.current = loadMore;
1214
-
1215
- // Keyed on the sentinel node itself (see `sentinel`'s own comment): it unmounts whenever the list shows
1216
- // "Loading..." and remounts afterwards, so an observer attached once to an earlier node would watch a
1217
- // detached element forever. Only ever rendered in "By date" mode, so no view-mode check is needed.
1218
- useEffect(() => {
1219
- if (!sentinel) {
1220
- return;
1221
- }
1222
- const observer = new IntersectionObserver(
1223
- (entries) => {
1224
- if (entries.some((entry) => entry.isIntersecting) && !loadMoreErrorRef.current) {
1225
- void loadMoreRef.current();
1226
- }
1227
- },
1228
- { root: scrollContainerRef.current, rootMargin: `${LOAD_MORE_ROOT_MARGIN_PX}px` },
1229
- );
1230
- observer.observe(sentinel);
1231
- return () => observer.disconnect();
1232
- }, [sentinel]);
1233
-
1234
- // An observer only reports *changes* - a sentinel still in view after a page lands (the new rows didn't
1235
- // push it out of view, e.g. the Focused/Other filter hid every one of them) never reports again, so
1236
- // keep loading while it's still in view. Only after a page that appended rows, or a full page of rows
1237
- // already shown (bounded - see `emptyPageStreakRef`), never after a failure, and only when the sentinel's
1238
- // real geometry says it's still in view (the observer's last report may predate the rows that just landed).
1239
- useEffect(() => {
1240
- if (appendedPageCount === 0) {
1241
- return;
1242
- }
1243
- // The scroll container is always mounted whenever a sentinel is (the sentinel lives inside it).
1244
- if (sentinel && isWithinLoadMoreRange(sentinel, scrollContainerRef.current!)) {
1245
- void loadMoreRef.current();
1246
- }
1247
- }, [appendedPageCount]);
1248
-
1249
- /**
1250
- * Replaces one listed row - and, in the conversation list, the opened message and the child row
1251
- * standing for it - with a newer copy the reading pane just produced.
1252
- *
1253
- * A conversation's *parent* row has no copy to replace: it is a summary of the whole thread, and its
1254
- * "2 unread" chip and bold styling come from a count the server worked out when the list was fetched.
1255
- * So when the reading pane reports a message it has just read (`previous` unread, `updated` read), that
1256
- * count is decremented here - otherwise a conversation kept claiming unread mail the reader had just
1257
- * read, until the whole list was reloaded.
1258
- */
1259
- function patchListedMessage(updated: Message, previous?: Message) {
1260
- setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m)));
1261
- // `ConversationList` fetched its own copy of this message when the thread was expanded; hand it the
1262
- // newer one so the child row doesn't keep showing a stale read/flag state.
1263
- setConversationPatches((prev) => ({ ...prev, [updated.uid]: updated }));
1264
- if (previous && !previous.flags.read && updated.flags.read) {
1265
- setConversations((prev) =>
1266
- prev.map((conversation) =>
1267
- conversation.messageUids.includes(updated.uid)
1268
- ? { ...conversation, unreadCount: Math.max(0, conversation.unreadCount - 1) }
1269
- : conversation,
1270
- ),
1271
- );
1272
- }
1273
- }
1274
-
1275
- function removeListedMessages(uids: Set<string>) {
1276
- setMessages((prev) => prev.filter((m) => !uids.has(m.uid)));
1277
- listedOffsetRef.current = Math.max(0, listedOffsetRef.current - uids.size);
1278
- setSelectedUid((prev) => (prev && uids.has(prev) ? null : prev));
1279
- }
1280
-
1281
- function removeListedMessage(uid: string) {
1282
- removeListedMessages(new Set([uid]));
1283
- }
1284
-
1285
- // The conversation list opens a whole thread in `ConversationThreadPane`, which loads and marks read
1286
- // its own messages; only the flat list feeds the single-message pane below.
1287
- const selected = preferences.showAsConversations ? null : (messages.find((m) => m.uid === selectedUid) ?? null);
1288
- const attachments = useMessageAttachments(selected);
1289
- useMarkMessageRead(selected, patchListedMessage);
1290
- // Search results can span every folder in the mailbox, not just the one selected in the sidebar - a
1291
- // selected message's own folderUid is the only reliable source for its actual folder type once
1292
- // searching (outside search, every message in `messages` already comes from `folderUid` itself, so
1293
- // this falls back to the sidebar selection unchanged). Aggregate views span every *mailbox* too, so
1294
- // the folder list consulted is the selected message's own mailbox's, not the shell's ambient one. A
1295
- // conversation spans folders for the same reason (an Inbox message and the Sent Items copy of its reply).
1296
- const spansFolders = isSearching || !!aggregateFolderType || preferences.showAsConversations;
1297
- const selectedFolderUid = spansFolders ? (selected?.folderUid ?? folderUid) : folderUid;
1298
- const selectedMailboxUid = spansFolders ? (selected?.mailboxUid ?? activeMailboxUid) : mailboxUid;
1299
- const folders = mailboxFolders.find((mf) => mf.mailbox.uid === selectedMailboxUid)?.folders ?? [];
1300
- const isSentItems = folders.find((f) => f.uid === selectedFolderUid)?.type === "sent_items";
1301
- const isOutbox = folders.find((f) => f.uid === selectedFolderUid)?.type === "outbox";
1302
- const draftsFolderUid = folders.find((f) => f.type === "drafts")?.uid;
1303
-
1304
- /** What a row actually shows as its subject - the decrypted one where this device recovered it, a
1305
- * readable stand-in for an encrypted one it hasn't, and a placeholder for a message with no subject. */
1306
- function rowSubject(message: Message): string {
1307
- return (
1308
- decryptedRows[message.uid]?.subject ||
1309
- (message.subject === ENCRYPTED_SUBJECT_PLACEHOLDER ? "Encrypted message" : message.subject) ||
1310
- "(no subject)"
1311
- );
1312
- }
1313
-
1314
- /** The conversation list's selection as plain messages: every message of every ticked conversation
1315
- * that is in the folder being listed. A conversation spans folders (an Inbox message and the Sent
1316
- * Items copy of its reply), and the list only ever showed this folder's half of it, so a bulk action
1317
- * from here must not reach into the other folders' copies either. */
1318
- const selectedConversationMessages = [...selectedConversationIds].flatMap((id) =>
1319
- (conversationMessagesById[id] ?? []).filter((m) => !folderUid || m.folderUid === folderUid),
1320
- );
1321
- const selectedMessages = preferences.showAsConversations
1322
- ? selectedConversationMessages
1323
- : messages.filter((m) => selectedUids.has(m.uid));
1324
- /** How many rows the list is actually showing - conversations or messages, whichever it lists. What the
1325
- * Select toggle is enabled by: there is nothing to select in a list with no rows. */
1326
- const listedRowCount = preferences.showAsConversations ? conversations.length : messages.length;
1327
- /** The conversation rows in the order the reader arranged them. The endpoint takes no sort parameters
1328
- * of its own (it pages by latest activity), so the arrangement is applied here, to the rows fetched so
1329
- * far - which the Sort menu says on screen. `conversations` itself stays in the order the pages
1330
- * arrived, so paging keeps appending to the same accumulated set. */
1331
- const listedConversations = sortConversations(conversations, preferences.sortBy, preferences.sortOrder);
1332
-
1333
- function leaveSelectMode() {
1334
- setSelectMode(false);
1335
- setSelectedUids(new Set());
1336
- setSelectedConversationIds(new Set());
1337
- setBulkError(null);
1338
- }
1339
-
1340
- function toggleSelected(uid: string) {
1341
- setSelectedUids((prev) => {
1342
- const next = new Set(prev);
1343
- if (next.has(uid)) {
1344
- next.delete(uid);
1345
- } else {
1346
- next.add(uid);
1347
- }
1348
- return next;
1349
- });
1350
- }
1351
-
1352
- /** Loads (once) the messages behind each of `ids`, so a ticked conversation resolves to the messages
1353
- * every bulk action below acts on. If any of them fails to load, none of that batch stays ticked and
1354
- * the bar says why - better than acting on the part of a selection that happened to arrive. */
1355
- async function resolveConversations(ids: string[]) {
1356
- const missing = ids.filter((id) => !conversationMessagesById[id]);
1357
- if (missing.length === 0) {
1358
- return;
1359
- }
1360
- setResolvingSelection((n) => n + 1);
1361
- try {
1362
- const loaded = await Promise.all(
1363
- missing.map(async (id) => [id, await listConversationMessages(activeMailboxUid, id)] as const),
1364
- );
1365
- setConversationMessagesById((prev) => ({ ...prev, ...Object.fromEntries(loaded) }));
1366
- } catch (err) {
1367
- setBulkError(
1368
- err instanceof ApiRequestError ? err.message : "Could not load the messages in one of those conversations.",
1369
- );
1370
- setSelectedConversationIds((prev) => {
1371
- const next = new Set(prev);
1372
- for (const id of missing) {
1373
- next.delete(id);
1374
- }
1375
- return next;
1376
- });
1377
- } finally {
1378
- setResolvingSelection((n) => n - 1);
1379
- }
1380
- }
1381
-
1382
- function toggleConversationSelected(conversation: ConversationSummary) {
1383
- const id = conversation.conversationId;
1384
- const ticking = !selectedConversationIds.has(id);
1385
- setSelectedConversationIds((prev) => {
1386
- const next = new Set(prev);
1387
- if (ticking) {
1388
- next.add(id);
1389
- } else {
1390
- next.delete(id);
1391
- }
1392
- return next;
1393
- });
1394
- if (ticking) {
1395
- void resolveConversations([id]);
1396
- }
1397
- }
1398
-
1399
- function selectAllConversations() {
1400
- const ids = conversations.map(conversationKey);
1401
- setSelectedConversationIds(new Set(ids));
1402
- void resolveConversations(ids);
1403
- }
1404
-
1405
- /**
1406
- * Runs one bulk action over the current selection, then either patches the affected rows in place or
1407
- * drops them (a move takes them out of the folder being listed).
1408
- *
1409
- * A bulk update is applied element by element server-side and stops at its first rejection, so a
1410
- * failure leaves an unknown prefix of the selection already changed (see `bulkUpdateMessages()`) -
1411
- * which is why a failure reloads the list rather than trying to reconcile it, and says so.
1412
- */
1413
- async function runBulkAction(action: (chosen: Message[]) => Promise<Message[]>, removesRows: boolean) {
1414
- const chosen = selectedMessages;
1415
- setBulkBusy(true);
1416
- setBulkError(null);
1417
- try {
1418
- const updated = await action(chosen);
1419
- if (preferences.showAsConversations) {
1420
- // A conversation row is a summary of its messages - its count, unread count, participants
1421
- // and preview all move when a bulk action changes or empties part of it - so the list is
1422
- // reloaded rather than patched row by row.
1423
- setSelectedConversationIds(new Set());
1424
- setConversationMessagesById({});
1425
- setRefreshKey((n) => n + 1);
1426
- } else if (removesRows) {
1427
- removeListedMessages(new Set(chosen.map((m) => m.uid)));
1428
- } else {
1429
- const byUid = new Map(updated.map((m) => [m.uid, m]));
1430
- setMessages((prev) => prev.map((m) => byUid.get(m.uid) ?? m));
1431
- }
1432
- setSelectedUids(new Set());
1433
- } catch (err) {
1434
- setBulkError(
1435
- `${err instanceof ApiRequestError ? err.message : "Those messages couldn't all be updated."} Some of them may already have changed, so the list has been reloaded.`,
1436
- );
1437
- setSelectedUids(new Set());
1438
- setSelectedConversationIds(new Set());
1439
- setConversationMessagesById({});
1440
- setRefreshKey((n) => n + 1);
1441
- } finally {
1442
- setBulkBusy(false);
1443
- }
1444
- }
1445
-
1446
- /** Archive has no folder to move into until the mailbox has one: the server creates it lazily on the
1447
- * first single-message archive, so that call both creates the folder and archives the first message,
1448
- * and the rest of the selection is then moved into the folder it reports. */
1449
- async function bulkArchive(chosen: Message[]): Promise<Message[]> {
1450
- const archiveFolderUid = currentFolders.find((f) => f.type === "archive")?.uid;
1451
- if (archiveFolderUid) {
1452
- return moveMessages(chosen, archiveFolderUid);
1453
- }
1454
- const first = await archiveMessage(chosen[0].uid);
1455
- const rest = chosen.slice(1);
1456
- return rest.length === 0 ? [first] : [first, ...(await moveMessages(rest, first.folderUid))];
1457
- }
1458
-
1459
- /**
1460
- * This mailbox's folder of `type`, created on demand. A mailbox is provisioned with only the folders it
1461
- * has needed so far, so Deleted Items and Junk may genuinely not exist the first time a selection is
1462
- * deleted or reported - and unlike Archive, neither has a server-side lazy-create route to go through.
1463
- *
1464
- * A folder created here isn't in `mailboxFolders` (the shell fetched that once), so it's remembered per
1465
- * mailbox and type until the page reloads; otherwise a second Delete would create a second folder.
1466
- */
1467
- async function resolveFolderOfType(type: Folder["type"], name: string): Promise<string> {
1468
- const key = `${activeMailboxUid}:${type}`;
1469
- const known = currentFolders.find((f) => f.type === type)?.uid ?? lazyFoldersRef.current.get(key);
1470
- if (known) {
1471
- return known;
1472
- }
1473
- const created = await createFolder({ mailboxUid: activeMailboxUid, name, type });
1474
- lazyFoldersRef.current.set(key, created.uid);
1475
- return created.uid;
1476
- }
1477
-
1478
- /**
1479
- * Sets every selected message's labels in one bulk update: each ends up with `labelUids`, plus the
1480
- * ones left partially applied (`keepPartial`) that it already had, plus any label this mailbox no
1481
- * longer defines - a label the menu couldn't show isn't one the reader chose to remove.
1482
- *
1483
- * `bulkUpdateMessages()` rather than `setMessagesLabels()`, which is its one-list-for-everyone special
1484
- * case: with a partially-applied row each message keeps a *different* list.
1485
- */
1486
- function applyLabelsToSelection(labelUids: string[], keepPartial: string[]) {
1487
- void runBulkAction(
1488
- (chosen) =>
1489
- bulkUpdateMessages(
1490
- chosen.map((message) => {
1491
- const kept = (message.labelUids ?? []).filter(
1492
- (uid) => keepPartial.includes(uid) || !mailboxLabels.some((label) => label.uid === uid),
1493
- );
1494
- return { uid: message.uid, version: message.version, labelUids: [...new Set([...labelUids, ...kept])] };
1495
- }),
1496
- ),
1497
- false,
1498
- );
1499
- }
1500
-
1501
- function moveSelectionToType(type: Folder["type"], name: string) {
1502
- void runBulkAction(async (chosen) => moveMessages(chosen, await resolveFolderOfType(type, name)), true);
1503
- }
1504
-
1505
- const loadMoreStatus = loadingMore ? (
1506
- "Loading more\u2026"
1507
- ) : loadMoreError ? (
1508
- <span className="inline-flex items-center gap-2">
1509
- <span role="alert" className="text-danger">
1510
- {loadMoreError}
1511
- </span>
1512
- <button type="button" onClick={() => void loadMoreRef.current()} className="text-primary-dark hover:underline font-medium">
1513
- Retry
1514
- </button>
1515
- </span>
1516
- ) : loadMoreStalled ? (
1517
- <button type="button" onClick={() => void loadMoreRef.current()} className="text-primary-dark hover:underline font-medium">
1518
- Load more
1519
- </button>
1520
- ) : null;
1521
-
1522
- function handleSelect(message: Message) {
1523
- if (selectMode) {
1524
- toggleSelected(message.uid);
1525
- return;
1526
- }
1527
- if (isMobile) {
1528
- window.location.href = `/messages/${encodeURIComponent(message.uid)}`;
1529
- return;
1530
- }
1531
- setSelectedUid(message.uid);
1532
- }
1533
-
1534
- /** Opens a conversation in the reading pane, positioned at one of its messages: the one a child row
1535
- * stands for, or the latest for a parent row. The thread pane loads the thread itself. */
1536
- function handleOpenConversation(conversation: ConversationSummary, uid: string) {
1537
- if (selectMode) {
1538
- // Same rule as `handleSelect()` for a message row: while selecting, a row's own button ticks
1539
- // the row rather than opening it.
1540
- toggleConversationSelected(conversation);
1541
- return;
1542
- }
1543
- if (isMobile) {
1544
- // No dedicated mobile thread route yet - the existing single-message detail route already
1545
- // handles any message uid regardless of conversation grouping.
1546
- window.location.href = `/messages/${encodeURIComponent(uid)}`;
1547
- return;
1548
- }
1549
- setSelectedUid(uid);
1550
- setOpenThread({ conversation, uid });
1551
- }
1552
-
1553
- if (!folderUid && !aggregateFolderType) {
1554
- // `MailShell` never renders this component at all until a mailbox is resolved (see its own
1555
- // full-screen `MailboxProvisioning` takeover otherwise) — this is purely the brief gap before
1556
- // that mailbox's own folder list has finished loading, not a "no mailbox" state. Distinct text
1557
- // from the message list's own "Loading…" below — otherwise the two transient states become
1558
- // indistinguishable to anything (a test, a user re-reading the screen) that catches this one.
1559
- return <p className="p-8 text-sm text-text-muted">Loading your mailbox&hellip;</p>;
1560
- }
1561
-
1562
- return (
1563
- <div className="flex h-full min-h-0">
1564
- <div ref={scrollContainerRef} className="w-full md:w-96 shrink-0 md:border-r border-border overflow-y-auto">
1565
- {selectMode ? (
1566
- <MailSelectionBar
1567
- selected={selectedMessages}
1568
- listed={messages}
1569
- totals={
1570
- preferences.showAsConversations
1571
- ? { selected: selectedConversationIds.size, listed: conversations.length, noun: "conversation" }
1572
- : undefined
1573
- }
1574
- onSelectAll={() =>
1575
- preferences.showAsConversations
1576
- ? selectAllConversations()
1577
- : setSelectedUids(new Set(messages.map((m) => m.uid)))
1578
- }
1579
- onClearSelection={() =>
1580
- preferences.showAsConversations ? setSelectedConversationIds(new Set()) : setSelectedUids(new Set())
1581
- }
1582
- onCancel={leaveSelectMode}
1583
- folders={currentFolders}
1584
- currentFolderUid={folderUid}
1585
- labels={mailboxLabels}
1586
- mailboxUid={activeMailboxUid}
1587
- onLabelCreated={(label) => setMailboxLabels((prev) => [...prev, label])}
1588
- onFolderCreated={onFolderCreated}
1589
- onApplyLabels={applyLabelsToSelection}
1590
- onSetRead={(read) => void runBulkAction((chosen) => setMessagesRead(chosen, read), false)}
1591
- onSetFlagged={(flagged) => void runBulkAction((chosen) => setMessagesFlagged(chosen, flagged), false)}
1592
- onArchive={() => void runBulkAction(bulkArchive, true)}
1593
- onMoveTo={(targetFolderUid) => runBulkAction((chosen) => moveMessages(chosen, targetFolderUid), true)}
1594
- onReportJunk={() => moveSelectionToType("junk", "Junk Email")}
1595
- onDelete={() => moveSelectionToType("deleted_items", "Deleted Items")}
1596
- busy={bulkBusy || resolvingSelection > 0}
1597
- error={bulkError}
1598
- />
1599
- ) : (
1600
- <MailListToolbar
1601
- sortBy={preferences.sortBy}
1602
- sortOrder={preferences.sortOrder}
1603
- filter={preferences.filter}
1604
- labelUids={preferences.labelUids}
1605
- labels={mailboxLabels}
1606
- mailboxUid={activeMailboxUid}
1607
- onLabelCreated={(label) => setMailboxLabels((prev) => [...prev, label])}
1608
- onLabelUidsChange={(nextLabelUids) => updatePreferences({ labelUids: nextLabelUids })}
1609
- showAsConversations={preferences.showAsConversations}
1610
- onSortChange={(sortBy, sortOrder) => updatePreferences({ sortBy, sortOrder })}
1611
- onFilterChange={(filter) => updatePreferences({ filter })}
1612
- onShowAsConversationsChange={(showAsConversations) => updatePreferences({ showAsConversations })}
1613
- selectMode={selectMode}
1614
- onSelectModeChange={setSelectMode}
1615
- offerClassificationFilters={offerClassificationFilters}
1616
- filterDisabled={isSearching}
1617
- filterDisabledReason="Filters don't apply to search results"
1618
- sortKeysDisabled={isSearching || !!aggregateFolderType}
1619
- // A conversation row is a thread summary, so only some of the keys have anything to
1620
- // order by - the rest stay pickable and are applied to the rows already fetched.
1621
- unavailableSortKeys={preferences.showAsConversations ? CONVERSATION_SORT_UNAVAILABLE : undefined}
1622
- sortKeysNote={
1623
- isSearching
1624
- ? "Search results are ranked by relevance rather than sorted."
1625
- : aggregateFolderType
1626
- ? "This view merges the newest mail from every mailbox and is always listed by date."
1627
- : preferences.showAsConversations
1628
- ? CONVERSATION_SORT_NOTE
1629
- : undefined
1630
- }
1631
- selectDisabled={!!aggregateFolderType || loading || listedRowCount === 0}
1632
- selectDisabledReason={
1633
- aggregateFolderType
1634
- ? "Open a mailbox's own folder to select messages"
1635
- : loading
1636
- ? "Wait for this folder to finish loading"
1637
- : "There is nothing here to select"
1638
- }
1639
- />
1640
- )}
1641
- {!preferences.showAsConversations && (
1642
- <div className="p-2 border-b border-border">
1643
- <input
1644
- type="search"
1645
- value={searchInput}
1646
- onChange={(e) => setSearchInput(e.target.value)}
1647
- placeholder={aggregateFolderType ? "Open a mailbox's own folder to search" : "Search all mail…"}
1648
- aria-label="Search all mail"
1649
- disabled={!!aggregateFolderType}
1650
- className="w-full text-sm px-3 py-1.5 rounded-md border border-border bg-surface disabled:opacity-55"
1651
- />
1652
- </div>
1653
- )}
1654
- {/* Two tabs, as Outlook has: Focused and Other. There is no "All" tab - the whole Inbox is
1655
- still one pick away, in the Filter menu, which is where every other named filter lives
1656
- and the only place that can show which of them is really in force. A stored `all` (or
1657
- Unread, Flagged, ...) therefore still lists what it always did, with neither tab
1658
- pressed, rather than being migrated into one of these two halves behind the reader's
1659
- back - see `MAIL_LIST_FILTERS`, which still offers it. */}
1660
- {offerClassificationFilters && (
1661
- <div className="flex border-b border-border text-xs">
1662
- {MAIL_LIST_CLASSIFICATION_FILTERS.map(({ value, label }) => (
1663
- <button
1664
- key={value}
1665
- type="button"
1666
- aria-pressed={preferences.filter === value}
1667
- onClick={() => updatePreferences({ filter: value })}
1668
- className={[
1669
- "flex-1 py-1.5 font-semibold",
1670
- preferences.filter === value
1671
- ? "text-primary-dark border-b-2 border-primary-dark"
1672
- : "text-text-muted",
1673
- ].join(" ")}
1674
- >
1675
- {label}
1676
- </button>
1677
- ))}
1678
- </div>
1679
- )}
1680
-
1681
- {isSearching && (
1682
- <div className="px-4 py-1.5 text-xs text-text-muted border-b border-border flex items-center justify-between gap-2">
1683
- {/* §_Progressive Results_: "Never show a hard count until every tier has reported.
1684
- Display n of ??, or omit the count. A settled count is the signal that ordering
1685
- is final." */}
1686
- <span>
1687
- {tier1Done && tier2Done && tier3Done
1688
- ? `${messages.length} result${messages.length === 1 ? "" : "s"}`
1689
- : `${messages.length} of ??`}
1690
- </span>
1691
- {tier2Done && !searchAllMail && (
1692
- <button
1693
- type="button"
1694
- onClick={handleSearchAllMail}
1695
- className="text-primary-dark hover:underline font-medium shrink-0"
1696
- >
1697
- Search all mail
1698
- </button>
1699
- )}
1700
- </div>
1701
- )}
1702
- {isSearching && coverage?.indexedFrom && (
1703
- <div className="px-4 py-1.5 text-xs text-text-muted border-b border-border">
1704
- Local search covers messages back to {new Date(coverage.indexedFrom).toLocaleDateString()}
1705
- {coverage.building ? " (still building)" : ""}
1706
- {searchAllMail
1707
- ? " - searching everything, not just recent mail."
1708
- : " - older encrypted mail is still searched, just slower."}
1709
- </div>
1710
- )}
1711
- {isSearching && !getUnlockedKeys(mailboxUid!) && (
1712
- <div className="px-4 py-2 border-b border-border bg-surface-alt">
1713
- <button
1714
- type="button"
1715
- onClick={handleUnlockSearch}
1716
- className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
1717
- >
1718
- <HiOutlineLockClosed size={12} aria-hidden="true" />
1719
- Unlock to include encrypted messages in these results
1720
- </button>
1721
- </div>
1722
- )}
1723
- {!isSearching && !preferences.showAsConversations && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (
1724
- <div className="px-4 py-2 border-b border-border bg-surface-alt">
1725
- <button
1726
- type="button"
1727
- onClick={handleUnlockList}
1728
- className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
1729
- >
1730
- <HiOutlineLockClosed size={12} aria-hidden="true" />
1731
- Unlock to show {undecryptedEncryptedUids.length === 1 ? "an encrypted message's" : "encrypted messages'"} subject
1732
- </button>
1733
- </div>
1734
- )}
1735
-
1736
- {error && (
1737
- <div className="p-4">
1738
- <Alert>{error}</Alert>
1739
- </div>
1740
- )}
1741
-
1742
- {loading ? (
1743
- <p className="p-4 text-sm text-text-muted">Loading&hellip;</p>
1744
- ) : preferences.showAsConversations ? (
1745
- <>
1746
- <ConversationList
1747
- conversations={listedConversations}
1748
- newestFirst={preferences.sortBy === "date" && preferences.sortOrder === "desc"}
1749
- mailboxUid={activeMailboxUid}
1750
- selectedUid={selectedUid}
1751
- messageOverrides={conversationPatches}
1752
- onOpenMessage={handleOpenConversation}
1753
- selectMode={selectMode}
1754
- selectedConversationIds={selectedConversationIds}
1755
- onToggleSelected={toggleConversationSelected}
1756
- />
1757
- {hasMore && (
1758
- <div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
1759
- {loadMoreStatus}
1760
- </div>
1761
- )}
1762
- </>
1763
- ) : messages.length === 0 ? (
1764
- <>
1765
- <p className="p-4 text-sm text-text-muted">
1766
- {isSearching
1767
- ? `No messages match "${searchQuery}".`
1768
- : effectiveFilter === "all"
1769
- ? "No messages in this folder."
1770
- : "No messages here."}
1771
- </p>
1772
- {/* Still offered while a filter hides every loaded row - what it's looking for may be
1773
- on a later page. */}
1774
- {hasMore && (
1775
- <div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
1776
- {loadMoreStatus}
1777
- </div>
1778
- )}
1779
- </>
1780
- ) : (
1781
- <>
1782
- <ul>
1783
- {messages.map((message) => (
1784
- <li key={message.uid} className="flex items-stretch border-b border-border">
1785
- {selectMode && (
1786
- <span className="shrink-0 flex items-center pl-3">
1787
- <input
1788
- type="checkbox"
1789
- checked={selectedUids.has(message.uid)}
1790
- onChange={() => toggleSelected(message.uid)}
1791
- aria-label={`Select ${rowSubject(message)}`}
1792
- className="w-4 h-4 accent-primary"
1793
- />
1794
- </span>
1795
- )}
1796
- <button
1797
- type="button"
1798
- onClick={() => handleSelect(message)}
1799
- className={[
1800
- "flex-1 min-w-0 text-left px-4 py-3",
1801
- message.uid === selectedUid || selectedUids.has(message.uid)
1802
- ? "bg-primary/10"
1803
- : "hover:bg-surface-alt",
1804
- message.flags.read ? "" : "font-semibold",
1805
- ].join(" ")}
1806
- >
1807
- <div className="flex items-center justify-between gap-2 text-sm">
1808
- <MailAddress recipient={message.from} />
1809
- <span className="text-xs text-text-muted shrink-0">
1810
- {new Date(message.receivedDate).toLocaleDateString()}
1811
- </span>
1812
- </div>
1813
- {aggregateFolderType && (
1814
- // The one view where a row needs to say which mailbox it came from.
1815
- <div className="text-xs text-text-muted truncate font-normal">
1816
- {mailboxes.find((mb) => mb.uid === message.mailboxUid)?.displayName}
1817
- </div>
1818
- )}
1819
- {isSearching && pendingUids.has(message.uid) ? (
1820
- // §_Progressive Results_: "Unresolved encrypted results MUST
1821
- // be rendered as skeleton entries in place, not appended on
1822
- // arrival." This uid is a Tier 1 metadataOnly guess Tier 2/3
1823
- // haven't confirmed (or ruled out) yet.
1824
- <div className="flex flex-col gap-1.5 py-0.5">
1825
- <Skeleton height="h-3.5" className="w-2/3 rounded-sm" />
1826
- <Skeleton height="h-3" className="w-full rounded-sm" />
1827
- </div>
1828
- ) : (
1829
- <>
1830
- <div className="text-sm truncate">{rowSubject(message)}</div>
1831
- <div className="flex items-center gap-2 text-xs text-text-muted font-normal">
1832
- <span className="truncate">
1833
- {snippets[message.uid] || decryptedRows[message.uid]?.preview || message.bodyPreview}
1834
- </span>
1835
- {message.hasAttachments && <HiOutlinePaperClip size={12} aria-label="Has attachments" />}
1836
- {message.flags.flagged && (
1837
- <HiOutlineFlag size={12} aria-label="Flagged" className="text-danger" />
1838
- )}
1839
- </div>
1840
- </>
1841
- )}
1842
- </button>
1843
- </li>
1844
- ))}
1845
- </ul>
1846
- {hasMore && (
1847
- <div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
1848
- {loadMoreStatus}
1849
- </div>
1850
- )}
1851
- {aggregateFolderType && (
1852
- <p className="p-4 text-center text-xs text-text-muted">
1853
- Showing the most recent mail from each mailbox. Open a specific mailbox&rsquo;s folder to
1854
- see older mail.
1855
- </p>
1856
- )}
1857
- </>
1858
- )}
1859
- </div>
1860
- {/* The reading pane's own height: a row of the full-height mail view, stretched to it by the
1861
- flex chain rather than by a percentage (an explicit height would opt it out of that
1862
- stretching), with `min-h-0` so a long message scrolls inside it instead of pushing it past
1863
- the window. Everything below - the thread pane, each message's `MessageDetailPane`, the
1864
- body iframe that cannot measure itself - takes its height from here, never from a `vh`
1865
- number of its own. */}
1866
- <div className="hidden md:flex flex-1 min-w-0 min-h-0">
1867
- {preferences.showAsConversations ? (
1868
- <ConversationThreadPane
1869
- conversation={openThread?.conversation ?? null}
1870
- selectedUid={openThread?.uid ?? null}
1871
- mailboxUid={activeMailboxUid}
1872
- folders={currentFolders}
1873
- labels={mailboxLabels}
1874
- onMessagePatched={patchListedMessage}
1875
- onMessageRemoved={(updated) => removeListedMessage(updated.uid)}
1876
- onLabelCreated={(label) => setMailboxLabels((prev) => [...prev, label])}
1877
- onFolderCreated={onFolderCreated}
1878
- />
1879
- ) : (
1880
- <MessageDetailPane
1881
- message={selected}
1882
- attachments={attachments}
1883
- isSentItems={isSentItems}
1884
- onRecalled={patchListedMessage}
1885
- isOutbox={isOutbox}
1886
- onReceiptHandled={patchListedMessage}
1887
- draftsFolderUid={draftsFolderUid}
1888
- folders={folders}
1889
- onMoved={(updated) => {
1890
- // Same reasoning as onArchived below - a move takes the message out of the folder
1891
- // being listed, so it leaves the list rather than being patched in place.
1892
- removeListedMessage(updated.uid);
1893
- }}
1894
- onFolderCreated={onFolderCreated}
1895
- onScheduledSendCanceled={(updated) => {
1896
- // The message moved out of the currently-viewed Outbox folder (into Drafts)
1897
- // - unlike a recall, which patches a message in place, this removes it from
1898
- // the list entirely, matching what a real folder switch would show.
1899
- removeListedMessage(updated.uid);
1900
- }}
1901
- onArchived={(updated) => {
1902
- // Same reasoning as onScheduledSendCanceled above - the message moved out of
1903
- // whichever folder is currently being viewed (into Archive), so it's removed
1904
- // from the list rather than patched in place.
1905
- removeListedMessage(updated.uid);
1906
- }}
1907
- labels={labels}
1908
- onLabelsChanged={patchListedMessage}
1909
- onLabelCreated={(label) =>
1910
- (otherMailbox ? setOtherMailboxLabels : setMailboxLabels)((prev) => [...prev, label])
1911
- }
1912
- />
1913
- )}
1914
- </div>
1915
- </div>
1916
- );
1917
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { routedPage } from "./_routedPage.js";
6
+ import { useNavigate } from "../shared/navigation/AppRouter.js";
7
+ import { whenIdle } from "../shared/navigation/idle.js";
8
+ import { prefetchComposeWindow } from "../shared/components/mail/compose/ComposeContext.js";
9
+ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
10
+ import { HiOutlineFlag, HiOutlineLockClosed, HiOutlinePaperClip } from "react-icons/hi2";
11
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
12
+ import {
13
+ Folder,
14
+ Mailbox,
15
+ Message,
16
+ MessageListFilter,
17
+ MessageListParams,
18
+ archiveMessage,
19
+ bulkUpdateMessages,
20
+ createFolder,
21
+ getMessage,
22
+ getMessageRawContent,
23
+ listFolders,
24
+ listMessages,
25
+ moveMessages,
26
+ setMessagesFlagged,
27
+ } from "@rapidmx/react-shared/mail/mailApi.js";
28
+ import { Label, listLabels } from "@rapidmx/react-shared/mail/labelsApi.js";
29
+ import {
30
+ ConversationListParams,
31
+ ConversationSummary,
32
+ listConversationMessages,
33
+ listConversations,
34
+ } from "@rapidmx/react-shared/mail/conversationsApi.js";
35
+ import { SearchResult, search as searchMailbox } from "@rapidmx/react-shared/search/searchApi.js";
36
+ import { parseSearchQuery, type ParsedSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
37
+ import { normalizeServerScores } from "@rapidmx/react-shared/search/searchScoring.js";
38
+ import { searchLocalIndex } from "../shared/search/searchTier2.js";
39
+ import type { Coverage } from "../shared/search/localIndexWorker.js";
40
+ import { getUnlockedKeys, subscribeKeySession, UnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
41
+ import { useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
42
+ import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
43
+ import MailShell, {
44
+ AggregateFolderType,
45
+ MailboxFolders,
46
+ MailShellProps,
47
+ useMailShell,
48
+ } from "../shared/components/mail/layout/MailShell.js";
49
+ import MailAddress from "../shared/components/mail/MailAddress.js";
50
+ import OutboxRowStatus from "../shared/components/mail/OutboxRowStatus.js";
51
+ import { mergeFirstPage } from "../shared/mail/mergeFirstPage.js";
52
+ import { listSnapshotKey, readListSnapshot, saveListScroll, writeListSnapshot } from "../shared/mail/listSnapshots.js";
53
+ import { setReadStateMany } from "../shared/mail/messageReadState.js";
54
+ import { useMarkMessageRead } from "../shared/mail/useMarkMessageRead.js";
55
+ import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "../shared/components/mail/unreadStyle.js";
56
+ import { LazyConversationThreadPane, LazyMessageDetailPane, prefetchReadingPane } from "../shared/components/mail/LazyReadingPane.js";
57
+ import ConversationList from "../shared/components/mail/ConversationList.js";
58
+ import { EncryptedPreview } from "../shared/components/mail/reading/EncryptedPreview.js";
59
+ import MailListToolbar from "../shared/components/mail/MailListToolbar.js";
60
+ import MailSelectionBar from "../shared/components/mail/MailSelectionBar.js";
61
+ import {
62
+ CONVERSATION_SORT_NOTE,
63
+ CONVERSATION_SORT_UNAVAILABLE,
64
+ MAIL_LIST_CLASSIFICATION_FILTERS,
65
+ MailListPreferences,
66
+ getMailListPreferences,
67
+ setMailListPreferences,
68
+ sortConversations,
69
+ } from "../shared/components/mail/listPreferences.js";
70
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
71
+ import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
72
+ import { useUnlockPrompt } from "../shared/components/layout/UnlockPromptProvider.js";
73
+ import { SHORTCUTS } from "../shared/keyboard/keymap.js";
74
+ import { isActivatable } from "../shared/keyboard/targets.js";
75
+ import { useShortcut } from "../shared/keyboard/useShortcut.js";
76
+ import { notifyApiError } from "../shared/notifications/apiErrors.js";
77
+
78
+ const MESSAGE_PAGE_SIZE = 50;
79
+ const SEARCH_DEBOUNCE_MS = 300;
80
+ const LIST_PREVIEW_MAX_LENGTH = 160;
81
+
82
+ /** The literal outer-envelope `Subject` every encrypted message carries server-side - RFC 9788's
83
+ * `hcp_baseline` policy obscures it to this exact string (see `smimeMessage.ts`'s
84
+ * `applyBaselineOuterHeaders()`), which is also all `Message.subject` ever shows for one of these until
85
+ * decrypted client-side. Used here to recognize which loaded rows are worth decrypting for display. */
86
+ const ENCRYPTED_SUBJECT_PLACEHOLDER = "[...]";
87
+
88
+ /** A row's client-recovered subject/preview, once decrypted - `undefined` fields mean nothing better
89
+ * than the placeholder/blank server value was recoverable for that field specifically. */
90
+ interface DecryptedRow {
91
+ subject?: string;
92
+ preview?: string;
93
+ }
94
+
95
+ /** Strips HTML down to plain text, for a short list-row preview of a decrypted body - mirrors
96
+ * `searchTier3.ts`'s own private `stripHtml()` (not currently exported from `@rapidmx/react-shared`,
97
+ * so duplicated here rather than pulled in through a package change just for this one small, pure
98
+ * helper). Not a security boundary - the output only ever feeds plain text display, truncated below,
99
+ * never rendered back into any DOM. */
100
+ function stripHtmlToText(html: string): string {
101
+ return html
102
+ .replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, " ")
103
+ .replace(/<[^>]+>/g, " ")
104
+ .replace(/&nbsp;/gi, " ")
105
+ .replace(/&amp;/gi, "&")
106
+ .replace(/&lt;/gi, "<")
107
+ .replace(/&gt;/gi, ">")
108
+ .replace(/&quot;/gi, '"')
109
+ .replace(/&#0*39;/gi, "'")
110
+ .replace(/\s+/g, " ")
111
+ .trim();
112
+ }
113
+
114
+ /**
115
+ * Decrypts the subject/preview of every currently-loaded row whose subject is still the RFC 9788
116
+ * placeholder (i.e. every encrypted message this device hasn't already resolved), keyed by uid - the
117
+ * inbox-list counterpart to `MessageDetailPane`'s own single-message decrypt and `searchTier3.ts`'s
118
+ * per-candidate decrypt. Bounded to `messages` (at most one loaded page, `MESSAGE_PAGE_SIZE`), never the
119
+ * whole mailbox - matching `searchEncryptedCandidates()`'s own "bounded, not everything" scope. Runs only
120
+ * once `unlocked` is available (the caller decides when to call this - see `InboxContent`'s own effect
121
+ * and `handleUnlockList()`), and a single row's fetch/decrypt failure never blocks the rest.
122
+ */
123
+ async function decryptEncryptedRows(messages: Message[], unlocked: UnlockedKeys): Promise<Record<string, DecryptedRow>> {
124
+ const encrypted = messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER);
125
+ const entries = await Promise.all(
126
+ encrypted.map(async (message): Promise<[string, DecryptedRow] | null> => {
127
+ try {
128
+ const rawMime = await getMessageRawContent(message.uid);
129
+ // Loaded here, on first use: the S/MIME code (PKI.js, ASN.1, X.509) is over half a megabyte and an inbox with
130
+ // no encrypted mail in it never needs it.
131
+ const { evaluateMessageSecurity } = await import("@rapidmx/react-shared/crypto/messageSecurity.js");
132
+ const security = await evaluateMessageSecurity(rawMime, unlocked);
133
+ if (!security.subject && !security.html) {
134
+ return null;
135
+ }
136
+ const preview = security.html ? stripHtmlToText(security.html).slice(0, LIST_PREVIEW_MAX_LENGTH) : undefined;
137
+ return [message.uid, { subject: security.subject, preview }];
138
+ } catch {
139
+ return null;
140
+ }
141
+ }),
142
+ );
143
+ const result: Record<string, DecryptedRow> = {};
144
+ for (const entry of entries) {
145
+ if (entry) {
146
+ result[entry[0]] = entry[1];
147
+ }
148
+ }
149
+ return result;
150
+ }
151
+
152
+ /** Merges Tier 1 (server, possibly `metadataOnly` for an encrypted message), Tier 2 (local index, fully
153
+ * decrypted and re-scored), and Tier 3 (server-narrowed candidates, decrypted and re-scored) results into
154
+ * one ranked list, per `specs/search.md` §7's "client MUST re-score all results it can see... normalise
155
+ * into the same space rather than interleaving raw scores": each tier is normalized independently via
156
+ * `normalizeServerScores()` before merging, since a Postgres/OpenSearch score, a local `bm25()` score,
157
+ * and this module's own Tier 3 term-count score all occupy unrelated ranges. A uid present in more than
158
+ * one list keeps only the last-inserted entry (Tier 2 wins over Tier 3 wins over Tier 1) - Tier 2 and
159
+ * Tier 3 both represent genuine, content-verified scores for the same message, so which one "wins" on
160
+ * overlap doesn't change correctness, only which of two equally-valid scores is shown; either supersedes
161
+ * Tier 1's metadata-only guess for the same uid.
162
+ *
163
+ * Called progressively - once per tier as it resolves, each time with whatever tiers have reported so
164
+ * far (an empty array for the rest) - by `InboxContent`'s own search orchestration below, per §_Progressive
165
+ * Results_' "reordering is permitted and preferred over appending." This function itself stays pure and
166
+ * stateless; it has no notion of "in progress" versus "final." */
167
+ function mergeSearchResults(tier1: SearchResult[], tier2: SearchResult[], tier3: SearchResult[]): SearchResult[] {
168
+ const normalizedTier1 = normalizeServerScores(tier1);
169
+ const normalizedTier2 = normalizeServerScores(tier2);
170
+ const normalizedTier3 = normalizeServerScores(tier3);
171
+ const merged = new Map<string, { result: SearchResult; normalizedScore: number }>();
172
+ for (const entry of normalizedTier1) {
173
+ merged.set(entry.result.entityUid, entry);
174
+ }
175
+ for (const entry of normalizedTier3) {
176
+ merged.set(entry.result.entityUid, entry);
177
+ }
178
+ for (const entry of normalizedTier2) {
179
+ merged.set(entry.result.entityUid, entry);
180
+ }
181
+ return Array.from(merged.values())
182
+ .sort((a, b) => b.normalizedScore - a.normalizedScore)
183
+ .map((entry) => entry.result);
184
+ }
185
+
186
+ /** `type:` narrows `entityTypes`; when absent this still defaults to `["message"]` — a non-message hit
187
+ * (contact/calendarEvent/note/task) has no `Message` to resolve via `getMessage()` below and is simply
188
+ * dropped by the same eventually-consistent-index fallback that already existed, rather than rendered
189
+ * (this inbox list only ever shows message rows; a real multi-entity-type results view is a separate,
190
+ * larger UI project outside this pass). Shared by both the fresh-search orchestration and `loadMore()`
191
+ * below, which each build this from the same `ParsedSearchQuery` differently only in `cursor`. */
192
+ function tier1SearchParams(parsed: ParsedSearchQuery, cursor: string | undefined, mailboxUid: string) {
193
+ return {
194
+ // The mailbox actually open - omitted, the server searches the caller's *own* mailbox, which is the
195
+ // wrong one whenever a shared mailbox's folder is being viewed.
196
+ mailboxUid,
197
+ types: parsed.entityTypes ?? ["message"],
198
+ cursor,
199
+ limit: MESSAGE_PAGE_SIZE,
200
+ from: parsed.from,
201
+ to: parsed.to,
202
+ cc: parsed.cc,
203
+ subject: parsed.subject,
204
+ hasAttachment: parsed.hasAttachment,
205
+ before: parsed.before,
206
+ after: parsed.after,
207
+ folderUid: parsed.folderUid,
208
+ flags: parsed.flags,
209
+ labels: parsed.labels,
210
+ };
211
+ }
212
+
213
+ /** How many of Tier 1's own `metadataOnly` hits (§7: "an encrypted entity matched only on server-visible
214
+ * metadata... MUST be rendered as skeleton entries in place... using the metadata score as a provisional
215
+ * position") are shown as skeleton rows at once - §_Progressive Results_' "Skeletons MUST be capped, at
216
+ * approximately one and a half pages." A non-`metadataOnly` Tier 1 hit (a real, already-scored content
217
+ * match — always the case for unencrypted mail) is never a skeleton and is never subject to this cap.
218
+ *
219
+ * A Tier 3 candidate that Tier 1 did *not* already surface has no provisional score/position of its own
220
+ * under the spec's own wording above, so it is deliberately never pre-rendered as a skeleton here either
221
+ * - it simply appears, fully resolved, once Tier 3 confirms it (see `InboxContent`'s search
222
+ * orchestration). */
223
+ const SKELETON_CAP = Math.round(MESSAGE_PAGE_SIZE * 1.5);
224
+
225
+ function capSkeletons(tier1Hits: SearchResult[]): SearchResult[] {
226
+ let skeletonsSeen = 0;
227
+ return tier1Hits.filter((hit) => {
228
+ if (!hit.metadataOnly) {
229
+ return true;
230
+ }
231
+ skeletonsSeen += 1;
232
+ return skeletonsSeen <= SKELETON_CAP;
233
+ });
234
+ }
235
+
236
+ /** How many candidates Tier 3 pulls per distinct search - larger than one page's worth so several
237
+ * `loadMore()` pages can be sliced from one decrypt pass (see `Tier3Cache` below) instead of a second,
238
+ * separately expensive server round trip and re-decrypt for the same query. Bounded, not unlimited - per
239
+ * this module's own `tier1SearchParams()` sibling, Tier 3's own candidate-narrowing is already the
240
+ * "heaviest single client-side cost" tier (`specs/search.md` §9's identical framing for attachment
241
+ * extraction); a query whose true candidate set exceeds this simply pages out once this cache is
242
+ * exhausted; `loadMore()` reflects that honestly via `hasMore`. */
243
+ const TIER3_CANDIDATE_LIMIT = 200;
244
+
245
+ /** One entry per distinct (mailbox, query, "search all mail" toggle, unlocked-or-not) combination this
246
+ * tab has already run Tier 3 for - keyed by `tier3CacheKey()` below. Tier 3's decrypt-and-match pass
247
+ * (`searchEncryptedCandidates()`) is by far this search's most expensive step, so it runs once per
248
+ * combination; every subsequent `loadMore()` page for that same combination slices further into the
249
+ * same already-decrypted array. Session-scoped, per tab, with no explicit eviction - a small map that's
250
+ * simply never read again once the query changes, the same shape `InboxContent`'s own `decryptedRows`
251
+ * state already accepts for a similar "worth keeping around, not worth actively pruning" tradeoff.
252
+ *
253
+ * The unlocked-or-not dimension matters: re-running an identical query right after an on-demand unlock
254
+ * (`handleUnlockSearch()`) MUST NOT reuse the "nothing to contribute" entry that same query cached while
255
+ * still locked - `searchEncryptedCandidates()` degrades to `[]` for an absent `unlocked`, and that empty
256
+ * result is exactly as cacheable/reusable as a real one, just under a different key. */
257
+ type Tier3Cache = Map<string, SearchResult[]>;
258
+
259
+ /** Keyed by the exact query windows Tier 3 actually ran (`tier3Windows()` - which already reflect the query,
260
+ * the "Search all mail" toggle, and Tier 2's coverage at the time), not just the query: the same query
261
+ * narrowed differently (a build finished, new mail moved the coverage end) must not reuse a result that was
262
+ * computed over a different date range. */
263
+ function tier3CacheKey(mailboxUid: string, windows: ParsedSearchQuery[], unlocked: boolean): string {
264
+ return `${mailboxUid}|${JSON.stringify(windows)}|${String(unlocked)}`;
265
+ }
266
+
267
+ /** A search query's cache/cursor identity - stable across re-parsing the identical raw text, and
268
+ * distinct for anything else (§8's "a query fingerprint, so a cursor cannot be replayed against a
269
+ * different query"). `JSON.stringify` on `ParsedSearchQuery` is deterministic here because every one of
270
+ * its own fields is a primitive or a `Date` (which serializes to a fixed ISO string) - no nested object
271
+ * whose key order could vary between two structurally-identical parses of the same text. */
272
+ function queryFingerprint(parsed: ParsedSearchQuery): string {
273
+ return JSON.stringify(parsed);
274
+ }
275
+
276
+ /** The date windows Tier 3 still has to search: the query's own range minus Tier 2's guaranteed coverage
277
+ * `[coverage.indexedFrom, coverage.indexedUntil]`, unless the reader explicitly asked to "Search all mail".
278
+ * Tier 2 already holds fully-decrypted, current content for that range, so re-fetching and re-decrypting it
279
+ * through Tier 3's slower candidate-narrowing path would be pure waste. Yields up to two windows - older than
280
+ * the coverage (`before:` tightened to `indexedFrom`) and newer than it (`after:` raised to `indexedUntil`,
281
+ * since nothing indexes mail that arrived after the build pass started) - never *widening* a bound the query
282
+ * already specified, and none at all when the query lies entirely inside the coverage.
283
+ *
284
+ * Only narrows once Tier 2 reports a finished, complete build pass from this session: while it's still
285
+ * building (or a pass stopped early - a failed folder listing, the byte budget) `indexedFrom` is just the
286
+ * oldest row that happens to be present, not a guarantee every encrypted message since then is indexed, and
287
+ * narrowing on it would silently drop encrypted results neither tier returns. */
288
+ function tier3Windows(parsed: ParsedSearchQuery, coverage: Coverage | undefined, searchAllMail: boolean): ParsedSearchQuery[] {
289
+ if (searchAllMail || !coverage?.indexedFrom || !coverage.indexedUntil || coverage.building || !coverage.complete) {
290
+ return [parsed];
291
+ }
292
+ const coveredFrom = new Date(coverage.indexedFrom);
293
+ const coveredUntil = new Date(coverage.indexedUntil);
294
+ const windows: ParsedSearchQuery[] = [];
295
+ if (!parsed.after || parsed.after.getTime() < coveredFrom.getTime()) {
296
+ windows.push({ ...parsed, before: parsed.before && parsed.before.getTime() < coveredFrom.getTime() ? parsed.before : coveredFrom });
297
+ }
298
+ if (!parsed.before || parsed.before.getTime() > coveredUntil.getTime()) {
299
+ windows.push({ ...parsed, after: parsed.after && parsed.after.getTime() > coveredUntil.getTime() ? parsed.after : coveredUntil });
300
+ }
301
+ return windows;
302
+ }
303
+
304
+ /** Runs Tier 3 over each window and merges the candidates (a uid can't match in two disjoint windows, but a
305
+ * message whose date sits exactly on a boundary may come back from both). */
306
+ async function searchTier3Windows(windows: ParsedSearchQuery[], unlocked: UnlockedKeys | undefined, mailboxUid: string): Promise<SearchResult[]> {
307
+ // Loaded on first use, with the S/MIME code it decrypts through (see `decryptEncryptedRows()`).
308
+ const { searchEncryptedCandidates } = await import("@rapidmx/react-shared/search/searchTier3.js");
309
+ const pages = await Promise.all(
310
+ windows.map((window) => searchEncryptedCandidates(window, unlocked, TIER3_CANDIDATE_LIMIT, { mailboxUid })),
311
+ );
312
+ const merged = new Map<string, SearchResult>();
313
+ for (const result of pages.flat()) {
314
+ if (!merged.has(result.entityUid)) {
315
+ merged.set(result.entityUid, result);
316
+ }
317
+ }
318
+ return [...merged.values()];
319
+ }
320
+
321
+ /** The paging state for one search, composited across all three tiers (`specs/search.md` §8) - opaque to
322
+ * every caller the same way `SearchResultPage.nextCursor` is opaque to callers of `search()` itself.
323
+ * Tier 1 keeps the server's own opaque cursor unmodified; Tier 2 (the local index) and Tier 3 (the
324
+ * cached, already-decrypted candidate array - see `Tier3Cache` above) are both this client's own state,
325
+ * so their "position" is just a plain offset into each. */
326
+ interface CompositeCursor {
327
+ tier1Cursor?: string;
328
+ tier2Offset: number;
329
+ tier3Offset: number;
330
+ /** The `Tier3Cache` entry the first page was sliced from - later pages keep slicing the same one. */
331
+ tier3Key: string;
332
+ fingerprint: string;
333
+ }
334
+
335
+ /** `undefined` for a missing, corrupted, or foreign-query cursor - every caller already treats "no
336
+ * cursor" as "start this tier from the beginning," so there's no separate error path needed here. */
337
+ function decodeCursor(raw: CompositeCursor | undefined, fingerprint: string): CompositeCursor | undefined {
338
+ return raw?.fingerprint === fingerprint ? raw : undefined;
339
+ }
340
+
341
+ /** Resolves every hit's `entityUid` to a full `Message` via `getMessage()`, reusing `cache` across
342
+ * repeated calls within the same search pass - `InboxContent`'s own search orchestration below re-merges
343
+ * and re-resolves the *entire* current hit set each time a tier resolves, so without this cache every
344
+ * stage would re-fetch messages an earlier stage already fetched. A hit whose message no longer resolves
345
+ * (deleted after being indexed, or the fetch itself failed) is cached as `null` and dropped - the same
346
+ * "a search hit can briefly outlive the message it points to" tolerance this function's inline
347
+ * predecessor already had. */
348
+ async function resolveHitsToMessages(
349
+ hits: SearchResult[],
350
+ cache: Map<string, Message | null>,
351
+ ): Promise<{ messages: Message[]; snippets: Record<string, string> }> {
352
+ const toFetch = hits.filter((hit) => !cache.has(hit.entityUid));
353
+ await Promise.all(
354
+ toFetch.map(async (hit) => {
355
+ const message = await getMessage(hit.entityUid).catch(() => null);
356
+ cache.set(hit.entityUid, message);
357
+ }),
358
+ );
359
+ const messages: Message[] = [];
360
+ const snippets: Record<string, string> = {};
361
+ for (const hit of hits) {
362
+ const message = cache.get(hit.entityUid);
363
+ if (!message) {
364
+ continue;
365
+ }
366
+ messages.push(message);
367
+ if (hit.snippet) {
368
+ snippets[message.uid] = hit.snippet;
369
+ }
370
+ }
371
+ return { messages, snippets };
372
+ }
373
+
374
+ /** How far outside the scroll container's visible area the load-more sentinel still counts as "in view". */
375
+ const LOAD_MORE_ROOT_MARGIN_PX = 200;
376
+
377
+ /** How many full pages in a row that added no new rows the list keeps loading on its own before it shows a
378
+ * "Load more" button instead. */
379
+ const MAX_EMPTY_PAGE_CONTINUATIONS = 3;
380
+
381
+ /** `true` when `more` has at least one row `shown` doesn't - i.e. appending it actually adds rows. Generic
382
+ * over the row's own identity so both the message list (`uid`) and the conversation list
383
+ * (`conversationId`) page the same way. */
384
+ function hasUnseenRows<T>(shown: T[], more: T[], idOf: (row: T) => string): boolean {
385
+ const seen = new Set(shown.map(idOf));
386
+ return more.some((row) => !seen.has(idOf(row)));
387
+ }
388
+
389
+ /** The load-more sentinel's actual current geometry against its scroll container, with the same margin the
390
+ * IntersectionObserver uses. */
391
+ function isWithinLoadMoreRange(sentinel: HTMLElement, root: HTMLElement): boolean {
392
+ const rect = sentinel.getBoundingClientRect();
393
+ const rootRect = root.getBoundingClientRect();
394
+ return rect.top <= rootRect.bottom + LOAD_MORE_ROOT_MARGIN_PX && rect.bottom >= rootRect.top - LOAD_MORE_ROOT_MARGIN_PX;
395
+ }
396
+
397
+ /** Appends `more` to `shown`, skipping any row already shown - a later page can repeat rows (Tier 1 and
398
+ * Tier 2/3 cursors advance independently, so the same message can come back from a different tier on a
399
+ * later page; a plain folder listing's pages shift when new mail arrives between fetches). Generic for the
400
+ * same reason `hasUnseenRows()` is. */
401
+ function appendUnseenRows<T>(shown: T[], more: T[], idOf: (row: T) => string): T[] {
402
+ const seen = new Set(shown.map(idOf));
403
+ const unseen: T[] = [];
404
+ for (const row of more) {
405
+ if (!seen.has(idOf(row))) {
406
+ seen.add(idOf(row));
407
+ unseen.push(row);
408
+ }
409
+ }
410
+ return unseen.length === 0 ? shown : [...shown, ...unseen];
411
+ }
412
+
413
+ const messageUid = (message: Message) => message.uid;
414
+ const conversationKey = (conversation: ConversationSummary) => conversation.conversationId;
415
+
416
+ /** Flattens and sorts a per-mailbox fetch into one merged, newest-first list - the aggregate ("All
417
+ * Inboxes" etc.) equivalent of `mergeSearchResults()` above, but simpler: an aggregated message has no
418
+ * natural relevance score to normalize, so this only ever sorts by `receivedDate`. */
419
+ function mergeInboxMessages(perMailbox: { mailbox: Mailbox; messages: Message[] }[]): Message[] {
420
+ return perMailbox
421
+ .flatMap((entry) => entry.messages)
422
+ .sort((a, b) => new Date(b.receivedDate).getTime() - new Date(a.receivedDate).getTime());
423
+ }
424
+
425
+ /**
426
+ * Fans out one `listMessages()` call per accessible mailbox that has a folder of `type`, merges the
427
+ * results newest-first. A mailbox with no matching folder, or whose fetch fails, simply contributes
428
+ * nothing - one mailbox's absence/failure must not blank out every other mailbox's messages.
429
+ *
430
+ * **Pagination scope trim (deliberate, matching this file's own documented Tier 2/3 tradeoffs)**: there is
431
+ * no composite cursor across an arbitrary number of independently-paginated mailboxes in this pass - this
432
+ * always fetches exactly each mailbox's own first page (`MESSAGE_PAGE_SIZE`) and the caller never offers a
433
+ * "load more" for the result (see `InboxContent`'s own `hasMore` handling in aggregate mode) - a real
434
+ * composite-cursor "load more" per mailbox is a natural v2 if usage shows people scrolling past the first
435
+ * page in aggregate view often.
436
+ */
437
+ async function fetchAggregateMessages(
438
+ mailboxFolders: MailboxFolders[],
439
+ type: AggregateFolderType,
440
+ filter: MessageListFilter,
441
+ ): Promise<Message[]> {
442
+ const perMailbox = await Promise.all(
443
+ mailboxFolders.map(async ({ mailbox, folders }) => {
444
+ const folder = folders.find((f) => f.type === type);
445
+ if (!folder) {
446
+ return { mailbox, messages: [] as Message[] };
447
+ }
448
+ // The filter is a server-side one per mailbox; the *sort* deliberately isn't offered here (see
449
+ // this function's own pagination scope trim) - each mailbox contributes its own newest page and
450
+ // they're merged newest-first, which a different sort key couldn't be made honest across an
451
+ // arbitrary number of independently-paged folders.
452
+ const messages = await listMessages(folder.uid, { limit: MESSAGE_PAGE_SIZE, filter }).catch(() => [] as Message[]);
453
+ return { mailbox, messages };
454
+ }),
455
+ );
456
+ return mergeInboxMessages(perMailbox);
457
+ }
458
+
459
+ function InboxPage(props: MailShellProps) {
460
+ return (
461
+ <MailShell {...props}>
462
+ <InboxContent userUid={props.userUid} />
463
+ </MailShell>
464
+ );
465
+ }
466
+
467
+ function InboxContent({ userUid }: { userUid?: string }) {
468
+ const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated, noteFolderUids, live, trackMessageChange } = useMailShell();
469
+ const isMobile = useIsMobile();
470
+ const navigate = useNavigate();
471
+ const { requestUnlock } = useUnlockPrompt();
472
+ const [messages, setMessages] = useState<Message[]>([]);
473
+ const [conversations, setConversations] = useState<ConversationSummary[]>([]);
474
+ const [loading, setLoading] = useState(true);
475
+ // Once a list is on screen and the browser has nothing better to do, fetch the code a click on a message (the reading pane)
476
+ // or on Compose/Reply (the compose window, with its editor) would otherwise wait for.
477
+ useEffect(() => {
478
+ if (loading) {
479
+ return;
480
+ }
481
+ return whenIdle(() => {
482
+ prefetchReadingPane();
483
+ prefetchComposeWindow();
484
+ });
485
+ }, [loading]);
486
+ const [loadingMore, setLoadingMore] = useState(false);
487
+ const [hasMore, setHasMore] = useState(false);
488
+ const [error, setError] = useState<string | null>(null);
489
+ const [selectedUid, setSelectedUid] = useState<string | null>(null);
490
+ // The thread the conversation list opened, and which of its messages was picked - the reading pane
491
+ // shows the whole conversation, positioned at that message (see `ConversationThreadPane`).
492
+ const [openThread, setOpenThread] = useState<{ conversation: ConversationSummary; uid: string } | null>(null);
493
+ // Messages the page has a newer copy of than `ConversationList` fetched (so far only the one the reading
494
+ // pane just marked read), applied over its own child rows so they don't stay bold after being read.
495
+ const [conversationPatches, setConversationPatches] = useState<Record<string, Message>>({});
496
+ // Folders this session created on demand for a bulk Delete/Report junk - see `resolveFolderOfType()`.
497
+ const lazyFoldersRef = useRef<Map<string, string>>(new Map());
498
+ const [selectMode, setSelectMode] = useState(false);
499
+ const [selectedUids, setSelectedUids] = useState<Set<string>>(new Set());
500
+ // Select mode over the *conversation* list ticks whole conversations rather than messages: the rows
501
+ // there are conversations, and a bulk action on one means "every message of it that is in this folder".
502
+ // Their messages are fetched (once, cached here) as each conversation is ticked, so the selection bar
503
+ // and every bulk action below keep working on the `Message[]` they already take.
504
+ const [selectedConversationIds, setSelectedConversationIds] = useState<Set<string>>(new Set());
505
+ const [conversationMessagesById, setConversationMessagesById] = useState<Record<string, Message[]>>({});
506
+ // A ticked conversation whose messages are still being fetched - every bulk action is held meanwhile,
507
+ // or it would act on a selection that is still arriving.
508
+ const [resolvingSelection, setResolvingSelection] = useState(0);
509
+ const [bulkBusy, setBulkBusy] = useState(false);
510
+ // Bumped to force the list effect below to re-run - a bulk update is deliberately neither atomic nor
511
+ // all-or-nothing (see `bulkUpdateMessages()`), so a rejection means refetching rather than guessing
512
+ // which half of the selection actually landed.
513
+ const [refreshKey, setRefreshKey] = useState(0);
514
+ const [searchInput, setSearchInput] = useState("");
515
+ const [searchQuery, setSearchQuery] = useState("");
516
+ const [snippets, setSnippets] = useState<Record<string, string>>({});
517
+ // The *open mailbox's* labels, for the Filter menu's Labels submenu, select mode's Apply label, and -
518
+ // unless the selected message belongs to another mailbox - the reading pane's own Labels menu.
519
+ const [mailboxLabels, setMailboxLabels] = useState<Label[]>([]);
520
+ // The selected message's own mailbox's labels, when that is a different mailbox from the open one: in
521
+ // search and aggregate views it can be, and its labels must not be offered as a filter for this one.
522
+ // Empty (and never fetched) otherwise - see `labels` below.
523
+ const [otherMailboxLabels, setOtherMailboxLabels] = useState<Label[]>([]);
524
+ // Tier 2's own reported window coverage for the current search - undefined outside a search, or
525
+ // before Tier 2 has resolved yet for this search pass.
526
+ const [coverage, setCoverage] = useState<Coverage | undefined>(undefined);
527
+ // Keyed by message uid - see decryptEncryptedRows(). Never cleared on folder/search switches (a
528
+ // decrypted row stays decrypted while its keys stay unlocked; re-decrypting on every navigation would
529
+ // waste work for no benefit) - only cleared when the mailbox's keys are locked (see the
530
+ // `subscribeKeySession()` effect below).
531
+ const [decryptedRows, setDecryptedRows] = useState<Record<string, DecryptedRow>>({});
532
+ // Bumped after a successful on-demand unlock to re-run the search effect below - it's not a
533
+ // dependency the effect could otherwise react to (getUnlockedKeys() is a plain module-level read, not
534
+ // React state; see keySession.ts's own doc comment).
535
+ const [unlockRefresh, setUnlockRefresh] = useState(0);
536
+ // §_Progressive Results_: which of the current search's uids are still an unconfirmed Tier 1
537
+ // `metadataOnly` guess (rendered as a skeleton row - see the JSX below) - empty outside a search, and
538
+ // always empty again once every tier has reported for the current pass (each unresolved entry is by
539
+ // then either confirmed, real content, or pruned - see the search orchestration effect).
540
+ const [pendingUids, setPendingUids] = useState<Set<string>>(new Set());
541
+ // Withholds a hard result count until every tier has reported for the current search pass (§_Progressive
542
+ // Results_: "Never show a hard count until every tier has reported... A settled count is the signal
543
+ // that ordering is final"). Reset on every fresh search; irrelevant outside search mode.
544
+ const [tier1Done, setTier1Done] = useState(false);
545
+ const [tier2Done, setTier2Done] = useState(false);
546
+ const [tier3Done, setTier3Done] = useState(false);
547
+ // Toggled by the "Search all mail" action next to the results count - removes Tier 3's own default
548
+ // bound (tightened to Tier 2's coverage window otherwise - see tightenBeforeToCoverage()) for one
549
+ // re-run. Stored as the mailbox/folder/query it was requested for, not a plain flag, so it resets the
550
+ // moment any of those change - derived in the same render, so the search effect never runs a new
551
+ // query with a previous query's unbounded Tier 3 window first.
552
+ const [searchAllMailKey, setSearchAllMailKey] = useState<string | null>(null);
553
+ const searchAllMailScope = `${mailboxUid ?? ""}\n${folderUid ?? ""}\n${searchQuery}`;
554
+ const searchAllMail = searchAllMailKey === searchAllMailScope;
555
+ // The mailbox unlock/decrypt call sites below treat as "the" mailbox when there's no single selected
556
+ // one (aggregate mode) - mirrors `MailShell`'s own identical `defaultMailboxUid` fallback. An
557
+ // aggregate-view row from a *different*, not-yet-unlocked mailbox stays locked until that mailbox's
558
+ // own folder view is opened directly - an accepted limitation, not a bug (see `MailShell`'s own doc
559
+ // comment on the same tradeoff for its `LocalIndexLifecycle`/`KeyEnrollmentGate` wiring).
560
+ const activeMailboxUid = mailboxUid ?? mailboxes.find((mb) => mb.ownerUserUid === userUid)?.uid ?? mailboxes[0]?.uid;
561
+ const mailboxKeys = mailboxes.find((mb) => mb.uid === activeMailboxUid)?.keys ?? [];
562
+ // The Sort/Filter menus' and "Show as conversations"' current settings, remembered per mailbox across
563
+ // reloads (`listPreferences.ts`). Read during render, not in an effect, so the very first listing
564
+ // already uses the remembered arrangement rather than fetching the default one and immediately
565
+ // refetching it - and kept per mailbox rather than as one value plus a "which mailbox is this?" check,
566
+ // so switching mailbox simply reads the other entry. What this session has changed wins over the store,
567
+ // which a storage-blocked browser refuses to keep.
568
+ const [preferencesByMailbox, setPreferencesByMailbox] = useState<Record<string, MailListPreferences>>({});
569
+ const preferences: MailListPreferences = preferencesByMailbox[activeMailboxUid] ?? getMailListPreferences(activeMailboxUid);
570
+ function updatePreferences(patch: Partial<MailListPreferences>) {
571
+ const next = { ...preferences, ...patch };
572
+ setPreferencesByMailbox((prev) => ({ ...prev, [activeMailboxUid]: next }));
573
+ setMailListPreferences(activeMailboxUid, next);
574
+ }
575
+ // Search stays real-folder-only - Tier 1/2/3 are all deeply mailbox/folder-scoped, and extending them
576
+ // to span an arbitrary number of mailboxes is out of scope for this pass (see the aggregate-fetch
577
+ // branch below, which the search effect never reaches while `folderUid` is unset).
578
+ const isSearching = !preferences.showAsConversations && searchQuery.length > 0 && !aggregateFolderType;
579
+ // Focused/Other is an Inbox-only concept (`FocusedInboxUtils.classifyMessage()` short-circuits to
580
+ // Focused for every other folder), and
581
+ // search results are ranked across folders rather than listed from one, so neither tab is offered
582
+ // there. Not offered for an aggregate view either (each mailbox classifies independently; merging
583
+ // that is out of scope).
584
+ const currentFolders = mailboxFolders.find((mf) => mf.mailbox.uid === activeMailboxUid)?.folders ?? [];
585
+ const currentFolderIsInbox = currentFolders.find((f) => f.uid === folderUid)?.type === "inbox";
586
+ const offerClassificationFilters = currentFolderIsInbox && !isSearching && !aggregateFolderType;
587
+ // A remembered Focused/Other filter must not silently narrow a folder that has no Focused Inbox to
588
+ // speak of - it stays remembered for when the Inbox is opened again, but doesn't apply meanwhile.
589
+ const effectiveFilter: MessageListFilter =
590
+ (preferences.filter === "focused" || preferences.filter === "other") && !offerClassificationFilters
591
+ ? "all"
592
+ : preferences.filter;
593
+ // Left out entirely rather than sent empty, so a list with no label filter asks for exactly the URL it
594
+ // always did.
595
+ const labelFilter = preferences.labelUids.length > 0 ? { labelUids: preferences.labelUids } : {};
596
+ // Every server-side list parameter the toolbar controls, in one place so the first page and each
597
+ // `loadMore()` page can't drift apart.
598
+ const listParams: MessageListParams = {
599
+ limit: MESSAGE_PAGE_SIZE,
600
+ sortBy: preferences.sortBy,
601
+ sortOrder: preferences.sortOrder,
602
+ filter: effectiveFilter,
603
+ ...labelFilter,
604
+ };
605
+ // A dependency of the list effect and of `loadMore()`, which can't take the array itself (a new one
606
+ // every render would refetch on every render).
607
+ const labelFilterKey = preferences.labelUids.join(",");
608
+ /** The sort the *server* is being asked for, as one dependency value. Empty while conversations are
609
+ * shown: `GET /mail/messages/conversations` takes no sort parameters at all, so those rows are ordered
610
+ * in the browser (`sortConversations()`) and rearranging them must not refetch the identical page -
611
+ * which would also collapse whichever conversations the reader had expanded. */
612
+ const serverSortKey = preferences.showAsConversations ? "" : `${preferences.sortBy}:${preferences.sortOrder}`;
613
+ /** The conversation list's own equivalent of `listParams` - a function because the page differs. */
614
+ function conversationParams(page: number): ConversationListParams {
615
+ return { folderUid, filter: effectiveFilter, ...labelFilter, page, limit: MESSAGE_PAGE_SIZE };
616
+ }
617
+ // How far into the folder's *current* server-side listing the rows fetched so far reach. Offset
618
+ // paging, not a cursor (`listMessages()` has none): a row removed locally (archived, scheduled send
619
+ // cancelled) also left the folder server-side, shifting every later message back by one - so each
620
+ // removal steps this back too, or the next page would silently skip a message. See `loadMore()`.
621
+ const listedOffsetRef = useRef(0);
622
+ const scrollContainerRef = useRef<HTMLDivElement | null>(null);
623
+ const searchInputRef = useRef<HTMLInputElement | null>(null);
624
+ // Where the message the keyboard just removed from the list (deleted, archived, moved) was: the next Down/Up continues from that spot, so
625
+ // clearing an inbox from the keyboard walks down it instead of jumping back to the top. Cleared by any selection.
626
+ const removedAnchorRef = useRef<number | null>(null);
627
+ // State (via a callback ref), not a plain ref: the sentinel mounts and unmounts as the list loads,
628
+ // filters, and empties, and the observer effect below must re-attach to whichever node is current.
629
+ const [sentinel, setSentinel] = useState<HTMLDivElement | null>(null);
630
+ const [loadMoreError, setLoadMoreError] = useState<string | null>(null);
631
+ const loadMoreErrorRef = useRef<string | null>(null);
632
+ loadMoreErrorRef.current = loadMoreError;
633
+ const loadMoreInFlightRef = useRef(false);
634
+ // Bumped by `loadMore()` for each page that added at least one row - the only event the continuation
635
+ // effect below keeps loading after. A counter rather than watching `loadingMore` flip back to false: a
636
+ // fast response can settle before React ever renders the `true`, so that flip isn't reliably observable.
637
+ const [appendedPageCount, setAppendedPageCount] = useState(0);
638
+ // Consecutive full pages that added no new rows (every row was already shown - e.g. new mail shifted the
639
+ // folder's pages). Such a page still counts as progress for the continuation effect, up to
640
+ // `MAX_EMPTY_PAGE_CONTINUATIONS` in a row; past that, `loadMoreStalled` shows a "Load more" button
641
+ // instead, so a server that keeps repeating rows is never looped on.
642
+ const emptyPageStreakRef = useRef(0);
643
+ const [loadMoreStalled, setLoadMoreStalled] = useState(false);
644
+ const messagesRef = useRef(messages);
645
+ messagesRef.current = messages;
646
+ const conversationsRef = useRef(conversations);
647
+ conversationsRef.current = conversations;
648
+ // Reset to a fresh Map at the start of every new search pass (see the search effect below) - see
649
+ // resolveHitsToMessages()'s own doc comment on why this needs to persist *within* one pass but not
650
+ // across passes (a stale `null` for a uid that's since become resolvable elsewhere must not stick).
651
+ const resolvedMessageCacheRef = useRef<Map<string, Message | null>>(new Map());
652
+ // Session-scoped, never explicitly cleared - see Tier3Cache's own doc comment above.
653
+ const tier3CacheRef = useRef<Tier3Cache>(new Map());
654
+ // The latest composite cursor this search pass has reached - read by loadMore(), written at the end
655
+ // of both the fresh-search orchestration and loadMore() itself. Not React state: it never drives a
656
+ // render on its own, only what loadMore() does with it later.
657
+ const compositeCursorRef = useRef<CompositeCursor | undefined>(undefined);
658
+ // Guards every async load below - each search stage, the plain folder/conversation/aggregate listings,
659
+ // and `loadMore()` - against a stale, still-in-flight pass clobbering state for a newer one that started
660
+ // after it (the query, folder, or view changed) - the same `loadSeq`-style monotonic-id pattern already used elsewhere in this codebase (e.g.
661
+ // `settings/privacy/index.tsx`'s `ExportSection`), generalized here across three independently-timed
662
+ // async stages instead of one.
663
+ const searchRunIdRef = useRef(0);
664
+ // Bumped whenever `activeMailboxUid`'s keys are locked (see the `subscribeKeySession()` effect below) - a
665
+ // decrypt that started before the lock checks it before storing its now-stale plaintext rows.
666
+ const lockGenerationRef = useRef(0);
667
+ // `messages` themselves aren't a dependency here on purpose - a message uid, once decrypted, is
668
+ // never re-decrypted just because the list re-renders with the same rows (e.g. a folder-unrelated
669
+ // state update elsewhere). New rows (a fresh page load, load-more, or a completed search) each
670
+ // re-trigger this the normal way, by changing `messages` itself.
671
+ //
672
+ // Scoped to `activeMailboxUid`'s own rows only - in aggregate mode `messages` can span several
673
+ // mailboxes, but only one mailbox's keys are ever being unlocked/tracked here (see `activeMailboxUid`'s
674
+ // own doc comment above); an encrypted row from any other mailbox simply isn't a candidate for this
675
+ // auto-decrypt or the manual unlock banner below.
676
+ const undecryptedEncryptedUids = messages
677
+ .filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER && !decryptedRows[m.uid] && m.mailboxUid === activeMailboxUid)
678
+ .map((m) => m.uid);
679
+
680
+ // Once unlocked, silently decrypt this page's own encrypted rows to show their real subject/preview -
681
+ // no prompt needed here, the same way searchEncryptedCandidates() already auto-includes decrypted
682
+ // matches once unlocked without asking again. Only the *first* unlock (or a fresh page of messages
683
+ // arriving) needs this; `handleUnlockList()` below covers the not-yet-unlocked case explicitly.
684
+ useEffect(() => {
685
+ if (undecryptedEncryptedUids.length === 0 || !activeMailboxUid) {
686
+ return;
687
+ }
688
+ const unlocked = getUnlockedKeys(activeMailboxUid);
689
+ if (!unlocked) {
690
+ return;
691
+ }
692
+ let cancelled = false;
693
+ const lockGeneration = lockGenerationRef.current;
694
+ void decryptEncryptedRows(
695
+ messages.filter((m) => undecryptedEncryptedUids.includes(m.uid)),
696
+ unlocked,
697
+ ).then((decrypted) => {
698
+ // A lock while this was in flight already cleared `decryptedRows` - never put them back.
699
+ if (!cancelled && lockGeneration === lockGenerationRef.current && Object.keys(decrypted).length > 0) {
700
+ setDecryptedRows((prev) => ({ ...prev, ...decrypted }));
701
+ }
702
+ });
703
+ return () => {
704
+ cancelled = true;
705
+ };
706
+ }, [messages, unlockRefresh]);
707
+
708
+ // Locking a mailbox's keys (idle timeout, "Destroy keys on this device now", sign-out) must take its
709
+ // decrypted content off screen too, not just out of memory: decrypted subjects/previews, search
710
+ // snippets (Tier 2/3 snippets are decrypted content), and the already-decrypted Tier 3 candidates. A
711
+ // live search re-runs, so Tier 2/3 contribute nothing again until the user unlocks. Read through a ref
712
+ // so the subscription itself doesn't churn on every render.
713
+ const keyLockStateRef = useRef({ activeMailboxUid, isSearching });
714
+ keyLockStateRef.current = { activeMailboxUid, isSearching };
715
+ useEffect(
716
+ () =>
717
+ subscribeKeySession(({ mailboxUid: changedMailboxUid, state }) => {
718
+ const current = keyLockStateRef.current;
719
+ if (state !== "locked" || changedMailboxUid !== current.activeMailboxUid) {
720
+ return;
721
+ }
722
+ lockGenerationRef.current += 1;
723
+ setDecryptedRows({});
724
+ setSnippets({});
725
+ tier3CacheRef.current.clear();
726
+ if (current.isSearching) {
727
+ setUnlockRefresh((n) => n + 1);
728
+ }
729
+ }),
730
+ [],
731
+ );
732
+
733
+ async function handleUnlockList() {
734
+ try {
735
+ const unlocked = await requestUnlock(activeMailboxUid, mailboxKeys);
736
+ const lockGeneration = lockGenerationRef.current;
737
+ const decrypted = await decryptEncryptedRows(
738
+ messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER && m.mailboxUid === activeMailboxUid),
739
+ unlocked,
740
+ );
741
+ if (lockGeneration === lockGenerationRef.current) {
742
+ setDecryptedRows((prev) => ({ ...prev, ...decrypted }));
743
+ }
744
+ } catch {
745
+ // User dismissed the unlock dialog - rows stay exactly as they were.
746
+ }
747
+ }
748
+
749
+ async function handleUnlockSearch() {
750
+ try {
751
+ await requestUnlock(activeMailboxUid, mailboxKeys);
752
+ setUnlockRefresh((n) => n + 1);
753
+ } catch {
754
+ // User dismissed the unlock dialog - the search results stay exactly as they were.
755
+ }
756
+ }
757
+
758
+ // Labels are mailbox-wide, not folder-scoped - fetched once per mailbox rather than per message, and
759
+ // handed to the detail pane below. A failure here just means the Labels control stays hidden (an empty
760
+ // `labels` array) rather than blocking the rest of the inbox. Keyed on the *selected message's own*
761
+ // mailbox: aggregate views and search results can show messages from a mailbox other than
762
+ // `activeMailboxUid`, and offering that mailbox's labels would let a label from the wrong mailbox be
763
+ // applied. The conversation view stays scoped to `activeMailboxUid`, the mailbox its threads come from.
764
+ const selectedMessageMailboxUid = messages.find((m) => m.uid === selectedUid)?.mailboxUid;
765
+ const labelsMailboxUid = preferences.showAsConversations ? activeMailboxUid : (selectedMessageMailboxUid ?? activeMailboxUid);
766
+ // Almost always the open mailbox's own labels, already fetched below - so `labels` reuses that list
767
+ // rather than asking for the identical one a second time (which is what this page did on every single
768
+ // view). Only a selected message from *another* mailbox - a search hit or an aggregate-view row - needs
769
+ // its own fetch, and only then is this state used at all.
770
+ const otherMailbox = labelsMailboxUid !== activeMailboxUid;
771
+ const labels = otherMailbox ? otherMailboxLabels : mailboxLabels;
772
+ // `labelsMailboxUid` is always set: `MailShell` only renders this component once at least one mailbox
773
+ // exists, so `activeMailboxUid` (its fallback) always resolves.
774
+ useEffect(() => {
775
+ if (!otherMailbox) {
776
+ setOtherMailboxLabels([]);
777
+ return;
778
+ }
779
+ let cancelled = false;
780
+ setOtherMailboxLabels([]);
781
+ listLabels(labelsMailboxUid, { limit: 200 })
782
+ .then((result) => {
783
+ if (!cancelled) {
784
+ setOtherMailboxLabels(result);
785
+ }
786
+ })
787
+ .catch(() => undefined);
788
+ return () => {
789
+ cancelled = true;
790
+ };
791
+ }, [labelsMailboxUid, otherMailbox]);
792
+
793
+ useEffect(() => {
794
+ let cancelled = false;
795
+ setMailboxLabels([]);
796
+ listLabels(activeMailboxUid, { limit: 200 })
797
+ .then((result) => {
798
+ if (!cancelled) {
799
+ setMailboxLabels(result);
800
+ }
801
+ })
802
+ .catch(() => undefined);
803
+ return () => {
804
+ cancelled = true;
805
+ };
806
+ }, [activeMailboxUid]);
807
+
808
+ // Debounce the raw input into the query actually searched, so every keystroke doesn't fire a request.
809
+ useEffect(() => {
810
+ const handle = setTimeout(() => setSearchQuery(searchInput.trim()), SEARCH_DEBOUNCE_MS);
811
+ return () => clearTimeout(handle);
812
+ }, [searchInput]);
813
+
814
+ // Choosing another folder no longer loads a new page, so what was being searched for would follow the reader into it: the
815
+ // search box and its results are cleared instead, as the page load used to. (The first selection resolves after mount - the
816
+ // shell reads the URL and lists the folders in effects - and is not a change of folder.)
817
+ const selectionKey = `${mailboxUid ?? ""}|${folderUid ?? ""}|${aggregateFolderType ?? ""}`;
818
+ const shownSelectionRef = useRef<string | null>(null);
819
+ useEffect(() => {
820
+ const shown = shownSelectionRef.current;
821
+ if (shown !== null && shown !== selectionKey) {
822
+ setSearchInput("");
823
+ setSearchQuery("");
824
+ }
825
+ if (folderUid || aggregateFolderType) {
826
+ shownSelectionRef.current = selectionKey;
827
+ }
828
+ }, [selectionKey]);
829
+
830
+ // Which listing this is, for the short-lived snapshot of each folder that makes going back to one instant (see
831
+ // `listSnapshots.ts`). A search, an aggregate view and a folder that hasn't resolved have no snapshot.
832
+ const listKey = listSnapshotKey({
833
+ mailboxUid: activeMailboxUid,
834
+ folderUid,
835
+ conversations: preferences.showAsConversations,
836
+ filter: effectiveFilter,
837
+ labels: labelFilterKey,
838
+ sort: serverSortKey,
839
+ });
840
+ const snapshotable = !!folderUid && !isSearching && !aggregateFolderType;
841
+ /** The key of the listing `messages`/`conversations` currently hold - not `listKey` while a new folder's rows are still coming. */
842
+ const shownListKeyRef = useRef<string | null>(null);
843
+ /** A scroll position to put the list back at once its rows are on screen (a snapshot's). */
844
+ const pendingScrollRef = useRef<number | null>(null);
845
+ useLayoutEffect(() => {
846
+ if (pendingScrollRef.current !== null && !loading && scrollContainerRef.current) {
847
+ scrollContainerRef.current.scrollTop = pendingScrollRef.current;
848
+ pendingScrollRef.current = null;
849
+ }
850
+ });
851
+ // Keeps the snapshot of the folder on screen current - rows added by "load more" or live updates, a read message, the selection.
852
+ useEffect(() => {
853
+ if (snapshotable && !loading && shownListKeyRef.current === listKey) {
854
+ writeListSnapshot(listKey, { messages, conversations, hasMore, selectedUid });
855
+ }
856
+ }, [snapshotable, loading, listKey, messages, conversations, hasMore, selectedUid]);
857
+
858
+ // Loads whichever list the current folder, arrangement and search state call for, and resets every
859
+ // piece of per-listing state (selection, paging, select mode) that a previous listing left behind.
860
+ useEffect(() => {
861
+ setSelectedUid(null);
862
+ setOpenThread(null);
863
+ setConversationPatches({});
864
+ setSelectedUids(new Set());
865
+ setSelectedConversationIds(new Set());
866
+ setConversationMessagesById({});
867
+ listedOffsetRef.current = 0;
868
+ compositeCursorRef.current = undefined;
869
+ setHasMore(false);
870
+ setLoadMoreError(null);
871
+ setLoadMoreStalled(false);
872
+ emptyPageStreakRef.current = 0;
873
+ // Every run - search or not - supersedes whatever an earlier run (or a `loadMore()` it started)
874
+ // still has in flight; each async callback below checks this before touching state.
875
+ searchRunIdRef.current += 1;
876
+ const myRunId = searchRunIdRef.current;
877
+ const isCurrentRun = () => searchRunIdRef.current === myRunId;
878
+ // Also invalidates on unmount, so nothing lands after the component is gone. Unconditional: this
879
+ // cleanup only ever runs for the latest run of this effect (React runs it before the next run
880
+ // bumps the id, or on unmount), so the id is always still `myRunId` here.
881
+ const invalidate = () => {
882
+ searchRunIdRef.current += 1;
883
+ };
884
+ // A folder shown a moment ago is put on screen at once and revalidated behind the rows; one that was not shows a skeleton.
885
+ const snapshot = snapshotable ? readListSnapshot(listKey) : undefined;
886
+
887
+ if (!folderUid && !aggregateFolderType) {
888
+ // The shell hasn't resolved this mailbox's folders yet (the render below says so). Listing
889
+ // anything now means a request whose answer is thrown away the moment the Inbox arrives - and
890
+ // for conversations a mailbox-wide grouping pass, the most expensive listing there is.
891
+ setMessages([]);
892
+ setConversations([]);
893
+ setLoading(false);
894
+ return;
895
+ }
896
+ if (aggregateFolderType && mailboxFolders.length === 0) {
897
+ // The same, for a merged view: it has no folder of its own, but it is built from every
898
+ // mailbox's folders, and this effect re-runs the moment they arrive. Listing twice is what
899
+ // that cost before.
900
+ setMessages([]);
901
+ setConversations([]);
902
+ setLoading(true);
903
+ return;
904
+ }
905
+
906
+ if (preferences.showAsConversations) {
907
+ // Conversations stay single-mailbox (not aggregated across mailboxes in this pass) - in
908
+ // aggregate mode this falls back to `activeMailboxUid`, the same mailbox unlock/labels use
909
+ // (always set - `MailShell` only renders this component once at least one mailbox exists).
910
+ // They're scoped to the selected folder (`folderUid`, absent only in aggregate mode), so the
911
+ // conversation list matches the folder the sidebar has selected rather than the whole mailbox.
912
+ if (snapshot) {
913
+ setConversations(snapshot.conversations);
914
+ listedOffsetRef.current = snapshot.conversations.length;
915
+ setHasMore(snapshot.hasMore);
916
+ shownListKeyRef.current = listKey;
917
+ pendingScrollRef.current = snapshot.scrollTop;
918
+ setLoading(false);
919
+ } else {
920
+ setLoading(true);
921
+ }
922
+ setError(null);
923
+ listConversations(activeMailboxUid, conversationParams(0))
924
+ .then((result) => {
925
+ if (isCurrentRun()) {
926
+ shownListKeyRef.current = listKey;
927
+ if (snapshot) {
928
+ // Folded into what is already shown, as a live refresh does, so the reader's place is kept.
929
+ const merged = mergeFirstPage(snapshot.conversations, result, conversationKey, MESSAGE_PAGE_SIZE);
930
+ setConversations(merged.rows);
931
+ listedOffsetRef.current = merged.complete ? result.length : snapshot.conversations.length + merged.added;
932
+ setHasMore(!merged.complete);
933
+ return;
934
+ }
935
+ setConversations(result);
936
+ listedOffsetRef.current = result.length;
937
+ setHasMore(result.length === MESSAGE_PAGE_SIZE);
938
+ }
939
+ })
940
+ .catch((err) => {
941
+ if (isCurrentRun()) {
942
+ setError(err instanceof ApiRequestError ? err.message : "Could not load conversations.");
943
+ }
944
+ })
945
+ .finally(() => {
946
+ if (isCurrentRun()) {
947
+ setLoading(false);
948
+ }
949
+ });
950
+ return invalidate;
951
+ }
952
+
953
+ if (aggregateFolderType) {
954
+ setLoading(true);
955
+ setError(null);
956
+ // hasMore stays false (set above) - see fetchAggregateMessages()'s own pagination scope trim.
957
+ void fetchAggregateMessages(mailboxFolders, aggregateFolderType, effectiveFilter)
958
+ .then((results) => {
959
+ if (isCurrentRun()) {
960
+ setMessages(results);
961
+ }
962
+ })
963
+ .finally(() => {
964
+ if (isCurrentRun()) {
965
+ setLoading(false);
966
+ }
967
+ });
968
+ return invalidate;
969
+ }
970
+
971
+ setLoading(!snapshot);
972
+ setError(null);
973
+
974
+ if (isSearching) {
975
+ setCoverage(undefined);
976
+ setPendingUids(new Set());
977
+ setTier1Done(false);
978
+ setTier2Done(false);
979
+ setTier3Done(false);
980
+ resolvedMessageCacheRef.current = new Map();
981
+ const parsed = parseSearchQuery(searchQuery);
982
+ const unlocked = getUnlockedKeys(mailboxUid!);
983
+ const fingerprint = queryFingerprint(parsed);
984
+
985
+ // Recomputes and re-renders the merged list from whatever tiers have reported so far -
986
+ // called once after Tier 1+2 (interim: unconfirmed Tier 1 metadataOnly hits render as
987
+ // skeletons) and again after Tier 3 (final: anything still unconfirmed is pruned instead -
988
+ // §_Progressive Results_' "Skeletons resolve or disappear"). Bails out via `myRunId` if a
989
+ // newer search pass has since started.
990
+ async function reveal(tier1Hits: SearchResult[], tier2Hits: SearchResult[], tier3Hits: SearchResult[], final: boolean) {
991
+ const capped = capSkeletons(tier1Hits);
992
+ const confirmed = new Set([...tier2Hits, ...tier3Hits].map((r) => r.entityUid));
993
+ const effectiveTier1 = final ? capped.filter((hit) => !hit.metadataOnly || confirmed.has(hit.entityUid)) : capped;
994
+ const merged = mergeSearchResults(effectiveTier1, tier2Hits, tier3Hits);
995
+ const { messages: resolved, snippets: resolvedSnippets } = await resolveHitsToMessages(
996
+ merged,
997
+ resolvedMessageCacheRef.current,
998
+ );
999
+ if (searchRunIdRef.current !== myRunId) {
1000
+ return;
1001
+ }
1002
+ setMessages(resolved);
1003
+ setSnippets(resolvedSnippets);
1004
+ if (final) {
1005
+ setPendingUids(new Set());
1006
+ } else {
1007
+ const pending = new Set<string>();
1008
+ for (const hit of capped) {
1009
+ if (hit.metadataOnly && !confirmed.has(hit.entityUid)) {
1010
+ pending.add(hit.entityUid);
1011
+ }
1012
+ }
1013
+ setPendingUids(pending);
1014
+ }
1015
+ }
1016
+
1017
+ void (async () => {
1018
+ try {
1019
+ const [tier1Page, tier2Page] = await Promise.all([
1020
+ searchMailbox(parsed.text, tier1SearchParams(parsed, undefined, mailboxUid!)),
1021
+ searchLocalIndex(mailboxUid!, parsed, unlocked, MESSAGE_PAGE_SIZE, 0),
1022
+ ]);
1023
+ if (searchRunIdRef.current !== myRunId) {
1024
+ return;
1025
+ }
1026
+ setTier1Done(true);
1027
+ setTier2Done(true);
1028
+ setCoverage(tier2Page.coverage);
1029
+ setLoading(false);
1030
+ await reveal(tier1Page.results, tier2Page.results, [], false);
1031
+
1032
+ const windows = tier3Windows(parsed, tier2Page.coverage, searchAllMail);
1033
+ const cacheKey = tier3CacheKey(mailboxUid!, windows, !!unlocked);
1034
+ let tier3Full = tier3CacheRef.current.get(cacheKey);
1035
+ if (!tier3Full) {
1036
+ tier3Full = await searchTier3Windows(windows, unlocked, mailboxUid!);
1037
+ if (searchRunIdRef.current !== myRunId) {
1038
+ return;
1039
+ }
1040
+ tier3CacheRef.current.set(cacheKey, tier3Full);
1041
+ }
1042
+ const tier3Page = tier3Full.slice(0, MESSAGE_PAGE_SIZE);
1043
+ setTier3Done(true);
1044
+ compositeCursorRef.current = {
1045
+ tier1Cursor: tier1Page.nextCursor,
1046
+ tier2Offset: tier2Page.results.length,
1047
+ tier3Offset: tier3Page.length,
1048
+ tier3Key: cacheKey,
1049
+ fingerprint,
1050
+ };
1051
+ setHasMore(!!tier1Page.nextCursor || tier2Page.hasMore || tier3Page.length < tier3Full.length);
1052
+ await reveal(tier1Page.results, tier2Page.results, tier3Page, true);
1053
+ } catch (err) {
1054
+ if (searchRunIdRef.current === myRunId) {
1055
+ setError(err instanceof ApiRequestError ? err.message : "Search failed.");
1056
+ setLoading(false);
1057
+ }
1058
+ }
1059
+ })();
1060
+ return invalidate;
1061
+ }
1062
+
1063
+ // Set: the guard at the top of this effect returned for a view with neither a folder nor an
1064
+ // aggregate type, and the aggregate branch above returned for the one with an aggregate type.
1065
+ if (snapshot) {
1066
+ setMessages(snapshot.messages);
1067
+ listedOffsetRef.current = snapshot.messages.length;
1068
+ setHasMore(snapshot.hasMore);
1069
+ // The selection the reader left the folder with, if that message is still there.
1070
+ setSelectedUid(snapshot.messages.some((m) => m.uid === snapshot.selectedUid) ? snapshot.selectedUid : null);
1071
+ shownListKeyRef.current = listKey;
1072
+ pendingScrollRef.current = snapshot.scrollTop;
1073
+ }
1074
+ listMessages(folderUid!, listParams)
1075
+ .then((results) => {
1076
+ if (isCurrentRun()) {
1077
+ shownListKeyRef.current = listKey;
1078
+ if (snapshot) {
1079
+ // Folded into what is already shown, as a live refresh does: the reader's place, selection and paged-in
1080
+ // rows stay, and a row they changed since (a higher version) is not put back.
1081
+ const merged = mergeFirstPage(snapshot.messages, results, messageUid, MESSAGE_PAGE_SIZE, (current, next) =>
1082
+ current.version >= next.version ? current : next,
1083
+ );
1084
+ setMessages(merged.rows);
1085
+ listedOffsetRef.current = merged.complete ? results.length : snapshot.messages.length + merged.added;
1086
+ setHasMore(!merged.complete);
1087
+ return;
1088
+ }
1089
+ setMessages(results);
1090
+ listedOffsetRef.current = results.length;
1091
+ setHasMore(results.length === MESSAGE_PAGE_SIZE);
1092
+ }
1093
+ })
1094
+ .catch((err) => {
1095
+ if (isCurrentRun()) {
1096
+ setError(err instanceof ApiRequestError ? err.message : "Could not load messages.");
1097
+ }
1098
+ })
1099
+ .finally(() => {
1100
+ if (isCurrentRun()) {
1101
+ setLoading(false);
1102
+ }
1103
+ });
1104
+ return invalidate;
1105
+ // `unlockRefresh`/`searchAllMail` are dependencies solely so `handleUnlockSearch()`/"Search all
1106
+ // mail" can force this effect to re-run the search above - Tier 3 (encrypted) results silently
1107
+ // contribute nothing without unlocked keys, so this is what actually makes them appear once the
1108
+ // user unlocks, and what makes "Search all mail" actually remove Tier 3's coverage bound. Neither
1109
+ // has any effect on the non-search branch below; re-running it with identical inputs just
1110
+ // re-fetches the same page.
1111
+ // `refreshKey` is a dependency for the same reason: a failed bulk action bumps it to refetch,
1112
+ // since a bulk update applies until its first rejection rather than all-or-nothing.
1113
+ }, [
1114
+ preferences.showAsConversations,
1115
+ serverSortKey,
1116
+ effectiveFilter,
1117
+ labelFilterKey,
1118
+ refreshKey,
1119
+ folderUid,
1120
+ mailboxUid,
1121
+ isSearching,
1122
+ searchQuery,
1123
+ unlockRefresh,
1124
+ searchAllMail,
1125
+ aggregateFolderType,
1126
+ // Only a merged view is built from the folder tree; for a single folder a folder appearing in the sidebar (the Outbox, Sent Items) must not reload
1127
+ // the list on screen and forget its selection.
1128
+ aggregateFolderType ? mailboxFolders : null,
1129
+ activeMailboxUid,
1130
+ ]);
1131
+
1132
+ // Which folders the messages on screen live in: one the sidebar does not know (the Outbox and Sent Items are made by the server on first use) is looked for -
1133
+ // its mailbox's folders are listed again, once - so it appears without a reload. A conversation names every folder it has a message in.
1134
+ useEffect(() => {
1135
+ noteFolderUids([...messages.map((message) => message.folderUid), ...conversations.flatMap((conversation) => conversation.folderUids)]);
1136
+ }, [messages, conversations, noteFolderUids]);
1137
+
1138
+ // New mail without a reload. `live` is bumped by a push event, a reconnect, the safety-net poll or the tab coming back (see
1139
+ // `useMailLiveUpdates()`); this then quietly refetches the first page of whatever is listed and folds it in - unlike the
1140
+ // effect above it resets nothing: not the selection, the open thread, select mode, the scroll position or the rows
1141
+ // already paged in, and nothing it fetches is marked read. It stands aside for a search (whose rows aren't a folder's),
1142
+ // a listing still loading and a "load more" in flight, and for events about folders the list isn't showing - a
1143
+ // conversation list, which can span folders, refreshes for any. Any failure is silent: the next tick tries again.
1144
+ const liveRunRef = useRef(0);
1145
+ useEffect(() => {
1146
+ if (live.tick === 0 || isSearching || loading || loadMoreInFlightRef.current || (!folderUid && !aggregateFolderType)) {
1147
+ return;
1148
+ }
1149
+ if (!preferences.showAsConversations && live.folderUids) {
1150
+ const shown = new Set(
1151
+ aggregateFolderType
1152
+ ? mailboxFolders.flatMap((entry) => entry.folders.filter((f) => f.type === aggregateFolderType).map((f) => f.uid))
1153
+ : [folderUid!],
1154
+ );
1155
+ if (![...live.folderUids].some((uid) => shown.has(uid))) {
1156
+ return;
1157
+ }
1158
+ }
1159
+ // Superseded by a newer refresh, or by any reload of the list itself (which bumps the search run id).
1160
+ const listRun = searchRunIdRef.current;
1161
+ const myRun = ++liveRunRef.current;
1162
+ const isCurrent = () => searchRunIdRef.current === listRun && liveRunRef.current === myRun;
1163
+ void (async () => {
1164
+ try {
1165
+ if (preferences.showAsConversations) {
1166
+ const fresh = await listConversations(activeMailboxUid, conversationParams(0));
1167
+ if (!isCurrent()) {
1168
+ return;
1169
+ }
1170
+ const merged = mergeFirstPage(conversationsRef.current, fresh, conversationKey, MESSAGE_PAGE_SIZE);
1171
+ setConversations(merged.rows);
1172
+ listedOffsetRef.current = merged.complete ? fresh.length : listedOffsetRef.current + merged.added;
1173
+ setHasMore(!merged.complete);
1174
+ } else if (aggregateFolderType) {
1175
+ // No paging here (see `fetchAggregateMessages()`): the fresh merged first pages are the list.
1176
+ const fresh = await fetchAggregateMessages(mailboxFolders, aggregateFolderType, effectiveFilter);
1177
+ if (isCurrent()) {
1178
+ setMessages(fresh);
1179
+ }
1180
+ } else {
1181
+ const fresh = await listMessages(folderUid!, listParams);
1182
+ if (!isCurrent()) {
1183
+ return;
1184
+ }
1185
+ // A row the reader has just changed (a higher version) is not put back by a fetch that began before.
1186
+ // Equal versions are the same server state, so the row on screen - which may carry an optimistic read/unread
1187
+ // flip the server has not answered yet - wins too.
1188
+ const merged = mergeFirstPage(messagesRef.current, fresh, messageUid, MESSAGE_PAGE_SIZE, (current, next) =>
1189
+ current.version >= next.version ? current : next,
1190
+ );
1191
+ setMessages(merged.rows);
1192
+ listedOffsetRef.current = merged.complete ? fresh.length : listedOffsetRef.current + merged.added;
1193
+ setHasMore(!merged.complete);
1194
+ }
1195
+ } catch {
1196
+ // Quiet by design - see above.
1197
+ }
1198
+ })();
1199
+ }, [live.tick]);
1200
+
1201
+ function handleSearchAllMail() {
1202
+ setSearchAllMailKey(searchAllMailScope);
1203
+ }
1204
+
1205
+ const loadMore = useCallback(async () => {
1206
+ const parsed = parseSearchQuery(searchQuery);
1207
+ const fingerprint = queryFingerprint(parsed);
1208
+ // While searching, a load-more continues from this query's own cursor - absent until its first page
1209
+ // has fully finished (the ref may still hold an earlier query's), in which case there's nowhere to
1210
+ // continue from yet.
1211
+ const searchCursor = decodeCursor(compositeCursorRef.current, fingerprint);
1212
+ if (
1213
+ loadMoreInFlightRef.current ||
1214
+ !hasMore ||
1215
+ loading ||
1216
+ (!preferences.showAsConversations && !folderUid) ||
1217
+ (isSearching && !searchCursor)
1218
+ ) {
1219
+ return;
1220
+ }
1221
+ // A ref, not `loadingMore` state: the observer and the continuation effect below can both call in the
1222
+ // same tick, each closing over a render where `loadingMore` was still false.
1223
+ loadMoreInFlightRef.current = true;
1224
+ setLoadMoreError(null);
1225
+ setLoadMoreStalled(false);
1226
+ setLoadingMore(true);
1227
+ /** Records whether a landed page added rows, and whether the continuation effect should keep going. */
1228
+ const notePageLanded = (addedRows: boolean, moreRemain: boolean) => {
1229
+ if (addedRows) {
1230
+ emptyPageStreakRef.current = 0;
1231
+ setAppendedPageCount((n) => n + 1);
1232
+ } else if (moreRemain) {
1233
+ emptyPageStreakRef.current += 1;
1234
+ if (emptyPageStreakRef.current <= MAX_EMPTY_PAGE_CONTINUATIONS) {
1235
+ setAppendedPageCount((n) => n + 1);
1236
+ } else {
1237
+ emptyPageStreakRef.current = 0;
1238
+ setLoadMoreStalled(true);
1239
+ }
1240
+ }
1241
+ };
1242
+ // A query/folder/view change while this page is in flight bumps the run id (see the effect above) -
1243
+ // its rows then belong to a list that's no longer on screen and must not be appended to the new one.
1244
+ const myRunId = searchRunIdRef.current;
1245
+ const isCurrentRun = () => searchRunIdRef.current === myRunId;
1246
+ try {
1247
+ if (preferences.showAsConversations) {
1248
+ const page = Math.floor(listedOffsetRef.current / MESSAGE_PAGE_SIZE);
1249
+ const more = await listConversations(activeMailboxUid, conversationParams(page));
1250
+ if (!isCurrentRun()) {
1251
+ return;
1252
+ }
1253
+ const addedRows = hasUnseenRows(conversationsRef.current, more, conversationKey);
1254
+ setConversations((prev) => appendUnseenRows(prev, more, conversationKey));
1255
+ setHasMore(more.length === MESSAGE_PAGE_SIZE);
1256
+ listedOffsetRef.current = page * MESSAGE_PAGE_SIZE + more.length;
1257
+ notePageLanded(addedRows, more.length === MESSAGE_PAGE_SIZE);
1258
+ } else if (isSearching) {
1259
+ const unlocked = getUnlockedKeys(mailboxUid!);
1260
+ const cursor = searchCursor!;
1261
+
1262
+ const [tier1Page, tier2Page] = await Promise.all([
1263
+ searchMailbox(parsed.text, tier1SearchParams(parsed, cursor.tier1Cursor, mailboxUid!)),
1264
+ searchLocalIndex(mailboxUid!, parsed, unlocked, MESSAGE_PAGE_SIZE, cursor.tier2Offset),
1265
+ ]);
1266
+ // The first page cached this pass under `tier3Key` before it created the cursor.
1267
+ const tier3Full = tier3CacheRef.current.get(cursor.tier3Key)!;
1268
+ const tier3Offset = cursor.tier3Offset;
1269
+ const tier3Page = tier3Full.slice(tier3Offset, tier3Offset + MESSAGE_PAGE_SIZE);
1270
+
1271
+ // Unlike the fresh-search pass above, a load-more page is resolved and appended in one
1272
+ // shot rather than progressively revealed - rows already on screen shouldn't reorder or
1273
+ // grow skeletons out from under a reader who has since scrolled past them. Any Tier 1
1274
+ // metadataOnly hit this page that neither Tier 2 nor Tier 3 (both already awaited above)
1275
+ // confirms simply keeps showing its existing placeholder text rather than a live skeleton
1276
+ // - a deliberate, documented scope trim of progressive reveal to the first page only.
1277
+ const merged = mergeSearchResults(capSkeletons(tier1Page.results), tier2Page.results, tier3Page);
1278
+ const { messages: more, snippets: moreSnippets } = await resolveHitsToMessages(merged, resolvedMessageCacheRef.current);
1279
+ if (!isCurrentRun()) {
1280
+ return;
1281
+ }
1282
+ const addedRows = hasUnseenRows(messagesRef.current, more, messageUid);
1283
+ setMessages((prev) => appendUnseenRows(prev, more, messageUid));
1284
+ setSnippets((prev) => ({ ...prev, ...moreSnippets }));
1285
+
1286
+ const nextTier3Offset = tier3Offset + tier3Page.length;
1287
+ compositeCursorRef.current = {
1288
+ tier1Cursor: tier1Page.nextCursor,
1289
+ tier2Offset: cursor.tier2Offset + tier2Page.results.length,
1290
+ tier3Offset: nextTier3Offset,
1291
+ tier3Key: cursor.tier3Key,
1292
+ fingerprint,
1293
+ };
1294
+ const moreRemain = !!tier1Page.nextCursor || tier2Page.hasMore || nextTier3Offset < tier3Full.length;
1295
+ setHasMore(moreRemain);
1296
+ notePageLanded(addedRows, moreRemain);
1297
+ } else {
1298
+ // The page containing the first message not fetched yet. After a local removal that offset is
1299
+ // no longer a page boundary, so this page overlaps rows already shown - `appendUnseenRows()`
1300
+ // drops those - rather than skipping the message that shifted back across the boundary.
1301
+ const page = Math.floor(listedOffsetRef.current / MESSAGE_PAGE_SIZE);
1302
+ const more = await listMessages(folderUid!, { ...listParams, page });
1303
+ if (!isCurrentRun()) {
1304
+ return;
1305
+ }
1306
+ const addedRows = hasUnseenRows(messagesRef.current, more, messageUid);
1307
+ setMessages((prev) => appendUnseenRows(prev, more, messageUid));
1308
+ setHasMore(more.length === MESSAGE_PAGE_SIZE);
1309
+ listedOffsetRef.current = page * MESSAGE_PAGE_SIZE + more.length;
1310
+ notePageLanded(addedRows, more.length === MESSAGE_PAGE_SIZE);
1311
+ }
1312
+ } catch (err) {
1313
+ // Shown next to the sentinel with a Retry button - never auto-retried (see the continuation
1314
+ // effect below), so a failing server isn't hammered while the sentinel stays in view.
1315
+ if (isCurrentRun()) {
1316
+ setLoadMoreError(err instanceof ApiRequestError ? err.message : "Could not load more messages.");
1317
+ }
1318
+ } finally {
1319
+ loadMoreInFlightRef.current = false;
1320
+ setLoadingMore(false);
1321
+ }
1322
+ }, [
1323
+ hasMore,
1324
+ loading,
1325
+ preferences.showAsConversations,
1326
+ serverSortKey,
1327
+ effectiveFilter,
1328
+ labelFilterKey,
1329
+ folderUid,
1330
+ isSearching,
1331
+ searchQuery,
1332
+ mailboxUid,
1333
+ activeMailboxUid,
1334
+ ]);
1335
+
1336
+ // Always calls the latest `loadMore` closure so the effect below doesn't need `loadMore` itself in its
1337
+ // dependency array (it changes on every keystroke/page load, which would otherwise mean nothing here).
1338
+ const loadMoreRef = useRef(loadMore);
1339
+ loadMoreRef.current = loadMore;
1340
+
1341
+ // Keyed on the sentinel node itself (see `sentinel`'s own comment): it unmounts whenever the list shows
1342
+ // "Loading..." and remounts afterwards, so an observer attached once to an earlier node would watch a
1343
+ // detached element forever. Only ever rendered in "By date" mode, so no view-mode check is needed.
1344
+ useEffect(() => {
1345
+ if (!sentinel) {
1346
+ return;
1347
+ }
1348
+ const observer = new IntersectionObserver(
1349
+ (entries) => {
1350
+ if (entries.some((entry) => entry.isIntersecting) && !loadMoreErrorRef.current) {
1351
+ void loadMoreRef.current();
1352
+ }
1353
+ },
1354
+ { root: scrollContainerRef.current, rootMargin: `${LOAD_MORE_ROOT_MARGIN_PX}px` },
1355
+ );
1356
+ observer.observe(sentinel);
1357
+ return () => observer.disconnect();
1358
+ }, [sentinel]);
1359
+
1360
+ // An observer only reports *changes* - a sentinel still in view after a page lands (the new rows didn't
1361
+ // push it out of view, e.g. the Focused/Other filter hid every one of them) never reports again, so
1362
+ // keep loading while it's still in view. Only after a page that appended rows, or a full page of rows
1363
+ // already shown (bounded - see `emptyPageStreakRef`), never after a failure, and only when the sentinel's
1364
+ // real geometry says it's still in view (the observer's last report may predate the rows that just landed).
1365
+ useEffect(() => {
1366
+ if (appendedPageCount === 0) {
1367
+ return;
1368
+ }
1369
+ // The scroll container is always mounted whenever a sentinel is (the sentinel lives inside it).
1370
+ if (sentinel && isWithinLoadMoreRange(sentinel, scrollContainerRef.current!)) {
1371
+ void loadMoreRef.current();
1372
+ }
1373
+ }, [appendedPageCount]);
1374
+
1375
+ /**
1376
+ * Replaces one listed row - and, in the conversation list, the opened message and the child row
1377
+ * standing for it - with a newer copy the reading pane just produced.
1378
+ *
1379
+ * A conversation's *parent* row has no copy to replace: it is a summary of the whole thread, and its
1380
+ * "2 unread" chip and bold styling come from a count the server worked out when the list was fetched.
1381
+ * So when a message changes read state (`previous` and `updated` differ in it - the optimistic copy of a message just
1382
+ * read, or its revert), that count moves by one here - otherwise a conversation kept claiming unread mail the reader
1383
+ * had just read, until the whole list was reloaded.
1384
+ */
1385
+ function patchListedMessage(updated: Message, previous?: Message) {
1386
+ setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m)));
1387
+ // `ConversationList` fetched its own copy of this message when the thread was expanded; hand it the
1388
+ // newer one so the child row doesn't keep showing a stale read/flag state.
1389
+ setConversationPatches((prev) => ({ ...prev, [updated.uid]: updated }));
1390
+ const unreadChange = previous ? Number(isUnread(updated)) - Number(isUnread(previous)) : 0;
1391
+ if (unreadChange !== 0) {
1392
+ setConversations((prev) =>
1393
+ prev.map((conversation) =>
1394
+ conversation.messageUids.includes(updated.uid)
1395
+ ? { ...conversation, unreadCount: Math.max(0, conversation.unreadCount + unreadChange) }
1396
+ : conversation,
1397
+ ),
1398
+ );
1399
+ }
1400
+ }
1401
+
1402
+ function removeListedMessages(uids: Set<string>) {
1403
+ const selectedIndex = messagesRef.current.findIndex((m) => m.uid === selectedUid);
1404
+ if (selectedUid && uids.has(selectedUid) && selectedIndex !== -1) {
1405
+ removedAnchorRef.current = selectedIndex;
1406
+ }
1407
+ setMessages((prev) => prev.filter((m) => !uids.has(m.uid)));
1408
+ listedOffsetRef.current = Math.max(0, listedOffsetRef.current - uids.size);
1409
+ setSelectedUid((prev) => (prev && uids.has(prev) ? null : prev));
1410
+ }
1411
+
1412
+ function removeListedMessage(uid: string) {
1413
+ removeListedMessages(new Set([uid]));
1414
+ }
1415
+
1416
+ // The conversation list opens a whole thread in `ConversationThreadPane`, which loads and marks read
1417
+ // its own messages; only the flat list feeds the single-message pane below.
1418
+ const selected = preferences.showAsConversations ? null : (messages.find((m) => m.uid === selectedUid) ?? null);
1419
+ const attachments = useMessageAttachments(selected);
1420
+ useMarkMessageRead(selected, patchListedMessage);
1421
+ // Search results can span every folder in the mailbox, not just the one selected in the sidebar - a
1422
+ // selected message's own folderUid is the only reliable source for its actual folder type once
1423
+ // searching (outside search, every message in `messages` already comes from `folderUid` itself, so
1424
+ // this falls back to the sidebar selection unchanged). Aggregate views span every *mailbox* too, so
1425
+ // the folder list consulted is the selected message's own mailbox's, not the shell's ambient one. A
1426
+ // conversation spans folders for the same reason (an Inbox message and the Sent Items copy of its reply).
1427
+ const spansFolders = isSearching || !!aggregateFolderType || preferences.showAsConversations;
1428
+ const selectedFolderUid = spansFolders ? (selected?.folderUid ?? folderUid) : folderUid;
1429
+ const selectedMailboxUid = spansFolders ? (selected?.mailboxUid ?? activeMailboxUid) : mailboxUid;
1430
+ const folders = mailboxFolders.find((mf) => mf.mailbox.uid === selectedMailboxUid)?.folders ?? [];
1431
+ const isSentItems = folders.find((f) => f.uid === selectedFolderUid)?.type === "sent_items";
1432
+ const isOutbox = folders.find((f) => f.uid === selectedFolderUid)?.type === "outbox";
1433
+ const draftsFolderUid = folders.find((f) => f.type === "drafts")?.uid;
1434
+
1435
+ /** What a row actually shows as its subject - the decrypted one where this device recovered it, a
1436
+ * readable stand-in for an encrypted one it hasn't, and a placeholder for a message with no subject. */
1437
+ function rowSubject(message: Message): string {
1438
+ return (
1439
+ decryptedRows[message.uid]?.subject ||
1440
+ (message.subject === ENCRYPTED_SUBJECT_PLACEHOLDER ? "Encrypted message" : message.subject) ||
1441
+ "(no subject)"
1442
+ );
1443
+ }
1444
+
1445
+ /** The conversation list's selection as plain messages: every message of every ticked conversation
1446
+ * that is in the folder being listed. A conversation spans folders (an Inbox message and the Sent
1447
+ * Items copy of its reply), and the list only ever showed this folder's half of it, so a bulk action
1448
+ * from here must not reach into the other folders' copies either. */
1449
+ const selectedConversationMessages = [...selectedConversationIds].flatMap((id) =>
1450
+ (conversationMessagesById[id] ?? []).filter((m) => !folderUid || m.folderUid === folderUid),
1451
+ );
1452
+ const selectedMessages = preferences.showAsConversations
1453
+ ? selectedConversationMessages
1454
+ : messages.filter((m) => selectedUids.has(m.uid));
1455
+ /** How many rows the list is actually showing - conversations or messages, whichever it lists. What the
1456
+ * Select toggle is enabled by: there is nothing to select in a list with no rows. */
1457
+ const listedRowCount = preferences.showAsConversations ? conversations.length : messages.length;
1458
+ /** The conversation rows in the order the reader arranged them. The endpoint takes no sort parameters
1459
+ * of its own (it pages by latest activity), so the arrangement is applied here, to the rows fetched so
1460
+ * far - which the Sort menu says on screen. `conversations` itself stays in the order the pages
1461
+ * arrived, so paging keeps appending to the same accumulated set. */
1462
+ const listedConversations = sortConversations(conversations, preferences.sortBy, preferences.sortOrder);
1463
+
1464
+ function leaveSelectMode() {
1465
+ setSelectMode(false);
1466
+ setSelectedUids(new Set());
1467
+ setSelectedConversationIds(new Set());
1468
+ }
1469
+
1470
+ function toggleSelected(uid: string) {
1471
+ setSelectedUids((prev) => {
1472
+ const next = new Set(prev);
1473
+ if (next.has(uid)) {
1474
+ next.delete(uid);
1475
+ } else {
1476
+ next.add(uid);
1477
+ }
1478
+ return next;
1479
+ });
1480
+ }
1481
+
1482
+ /** Loads (once) the messages behind each of `ids`, so a ticked conversation resolves to the messages
1483
+ * every bulk action below acts on. If any of them fails to load, none of that batch stays ticked and
1484
+ * a pop-up says why - better than acting on the part of a selection that happened to arrive. */
1485
+ async function resolveConversations(ids: string[]) {
1486
+ const missing = ids.filter((id) => !conversationMessagesById[id]);
1487
+ if (missing.length === 0) {
1488
+ return;
1489
+ }
1490
+ setResolvingSelection((n) => n + 1);
1491
+ try {
1492
+ const loaded = await Promise.all(
1493
+ missing.map(async (id) => [id, await listConversationMessages(activeMailboxUid, id)] as const),
1494
+ );
1495
+ setConversationMessagesById((prev) => ({ ...prev, ...Object.fromEntries(loaded) }));
1496
+ } catch (err) {
1497
+ notifyApiError(err, "Couldn't load the messages in one of those conversations");
1498
+ setSelectedConversationIds((prev) => {
1499
+ const next = new Set(prev);
1500
+ for (const id of missing) {
1501
+ next.delete(id);
1502
+ }
1503
+ return next;
1504
+ });
1505
+ } finally {
1506
+ setResolvingSelection((n) => n - 1);
1507
+ }
1508
+ }
1509
+
1510
+ function toggleConversationSelected(conversation: ConversationSummary) {
1511
+ const id = conversation.conversationId;
1512
+ const ticking = !selectedConversationIds.has(id);
1513
+ setSelectedConversationIds((prev) => {
1514
+ const next = new Set(prev);
1515
+ if (ticking) {
1516
+ next.add(id);
1517
+ } else {
1518
+ next.delete(id);
1519
+ }
1520
+ return next;
1521
+ });
1522
+ if (ticking) {
1523
+ void resolveConversations([id]);
1524
+ }
1525
+ }
1526
+
1527
+ function selectAllConversations() {
1528
+ const ids = conversations.map(conversationKey);
1529
+ setSelectedConversationIds(new Set(ids));
1530
+ void resolveConversations(ids);
1531
+ }
1532
+
1533
+ /**
1534
+ * Runs one bulk action over the current selection, then either patches the affected rows in place or
1535
+ * drops them (a move takes them out of the folder being listed).
1536
+ *
1537
+ * A bulk update is applied element by element server-side and stops at its first rejection, so a
1538
+ * failure leaves an unknown prefix of the selection already changed (see `bulkUpdateMessages()`) -
1539
+ * which is why a failure reloads the list rather than trying to reconcile it, and says so ("all of those messages" in the pop-up's
1540
+ * title: some may have changed).
1541
+ *
1542
+ * `chosen` is the selection bar's ticked messages unless the caller (a keyboard shortcut acting on the one open message or conversation)
1543
+ * names its own. Resolves whether it succeeded.
1544
+ */
1545
+ async function runBulkAction(
1546
+ action: (chosen: Message[]) => Promise<Message[]>,
1547
+ removesRows: boolean,
1548
+ chosen: Message[] = selectedMessages,
1549
+ ): Promise<boolean> {
1550
+ setBulkBusy(true);
1551
+ try {
1552
+ const updated = await action(chosen);
1553
+ if (removesRows) {
1554
+ // A move (Delete, Archive, Report junk, Move to): the source folders' badges go down, the target's up. Reading
1555
+ // and unreading are tracked by `setReadStateMany()` itself, as they happen.
1556
+ const movedByUid = new Map(updated.map((m) => [m.uid, m]));
1557
+ for (const before of chosen) {
1558
+ const after = movedByUid.get(before.uid);
1559
+ if (after) {
1560
+ trackMessageChange(before, after).settle();
1561
+ }
1562
+ }
1563
+ }
1564
+ if (preferences.showAsConversations) {
1565
+ // A conversation row is a summary of its messages - its count, unread count, participants
1566
+ // and preview all move when a bulk action changes or empties part of it - so the list is
1567
+ // reloaded rather than patched row by row.
1568
+ setSelectedConversationIds(new Set());
1569
+ setConversationMessagesById({});
1570
+ setRefreshKey((n) => n + 1);
1571
+ } else if (removesRows) {
1572
+ removeListedMessages(new Set(chosen.map((m) => m.uid)));
1573
+ } else {
1574
+ const byUid = new Map(updated.map((m) => [m.uid, m]));
1575
+ setMessages((prev) => prev.map((m) => byUid.get(m.uid) ?? m));
1576
+ }
1577
+ setSelectedUids(new Set());
1578
+ return true;
1579
+ } catch (err) {
1580
+ notifyApiError(err, chosen.length === 1 ? "Couldn't update the message" : "Couldn't update all of those messages");
1581
+ setSelectedUids(new Set());
1582
+ setSelectedConversationIds(new Set());
1583
+ setConversationMessagesById({});
1584
+ setRefreshKey((n) => n + 1);
1585
+ return false;
1586
+ } finally {
1587
+ setBulkBusy(false);
1588
+ }
1589
+ }
1590
+
1591
+ /** Archive has no folder to move into until the mailbox has one: the server creates it lazily on the
1592
+ * first single-message archive, so that call both creates the folder and archives the first message,
1593
+ * and the rest of the selection is then moved into the folder it reports. */
1594
+ async function bulkArchive(chosen: Message[]): Promise<Message[]> {
1595
+ const archiveFolderUid = currentFolders.find((f) => f.type === "archive")?.uid;
1596
+ if (archiveFolderUid) {
1597
+ return moveMessages(chosen, archiveFolderUid);
1598
+ }
1599
+ const first = await archiveMessage(chosen[0].uid);
1600
+ const rest = chosen.slice(1);
1601
+ return rest.length === 0 ? [first] : [first, ...(await moveMessages(rest, first.folderUid))];
1602
+ }
1603
+
1604
+ /**
1605
+ * This mailbox's folder of `type`. The server makes every well-known folder itself (when the mailbox is made, and again when the folders are listed
1606
+ * if one is missing), so a folder the page's tree lacks is asked for - the tree may simply be out of date - and used, never created a second time: a
1607
+ * duplicate of a well-known type is invisible to the server (it answers with the oldest) but would be a second row in the sidebar. Only a server that
1608
+ * really has none (an older one, which makes Deleted Items and Junk on first use) is asked to create it.
1609
+ *
1610
+ * A folder found or created here may not be in `mailboxFolders` yet, so it is filed there and remembered per mailbox and type until the page
1611
+ * reloads; otherwise a second Delete would ask again.
1612
+ */
1613
+ async function resolveFolderOfType(type: Folder["type"], name: string): Promise<string> {
1614
+ const key = `${activeMailboxUid}:${type}`;
1615
+ const known = currentFolders.find((f) => f.type === type)?.uid ?? lazyFoldersRef.current.get(key);
1616
+ if (known) {
1617
+ return known;
1618
+ }
1619
+ const listed = (await listFolders(activeMailboxUid)).find((f) => f.type === type);
1620
+ if (listed) {
1621
+ onFolderCreated(listed);
1622
+ lazyFoldersRef.current.set(key, listed.uid);
1623
+ return listed.uid;
1624
+ }
1625
+ const created = await createFolder({ mailboxUid: activeMailboxUid, name, type });
1626
+ lazyFoldersRef.current.set(key, created.uid);
1627
+ return created.uid;
1628
+ }
1629
+
1630
+ /**
1631
+ * Sets every selected message's labels in one bulk update: each ends up with `labelUids`, plus the
1632
+ * ones left partially applied (`keepPartial`) that it already had, plus any label this mailbox no
1633
+ * longer defines - a label the menu couldn't show isn't one the reader chose to remove.
1634
+ *
1635
+ * `bulkUpdateMessages()` rather than `setMessagesLabels()`, which is its one-list-for-everyone special
1636
+ * case: with a partially-applied row each message keeps a *different* list.
1637
+ */
1638
+ function applyLabelsToSelection(labelUids: string[], keepPartial: string[]) {
1639
+ void runBulkAction(
1640
+ (chosen) =>
1641
+ bulkUpdateMessages(
1642
+ chosen.map((message) => {
1643
+ const kept = (message.labelUids ?? []).filter(
1644
+ (uid) => keepPartial.includes(uid) || !mailboxLabels.some((label) => label.uid === uid),
1645
+ );
1646
+ return { uid: message.uid, version: message.version, labelUids: [...new Set([...labelUids, ...kept])] };
1647
+ }),
1648
+ ),
1649
+ false,
1650
+ );
1651
+ }
1652
+
1653
+ function moveSelectionToType(type: Folder["type"], name: string, chosen?: Message[]) {
1654
+ return runBulkAction(async (moving) => moveMessages(moving, await resolveFolderOfType(type, name)), true, chosen);
1655
+ }
1656
+
1657
+ const loadMoreStatus = loadingMore ? (
1658
+ "Loading more\u2026"
1659
+ ) : loadMoreError ? (
1660
+ <span className="inline-flex items-center gap-2">
1661
+ <span role="alert" className="text-danger">
1662
+ {loadMoreError}
1663
+ </span>
1664
+ <button type="button" onClick={() => void loadMoreRef.current()} className="text-primary-dark hover:underline font-medium">
1665
+ Retry
1666
+ </button>
1667
+ </span>
1668
+ ) : loadMoreStalled ? (
1669
+ <button type="button" onClick={() => void loadMoreRef.current()} className="text-primary-dark hover:underline font-medium">
1670
+ Load more
1671
+ </button>
1672
+ ) : null;
1673
+
1674
+ function handleSelect(message: Message) {
1675
+ if (selectMode) {
1676
+ toggleSelected(message.uid);
1677
+ return;
1678
+ }
1679
+ if (isMobile) {
1680
+ navigate(`/messages/${encodeURIComponent(message.uid)}`);
1681
+ return;
1682
+ }
1683
+ removedAnchorRef.current = null;
1684
+ setSelectedUid(message.uid);
1685
+ }
1686
+
1687
+ /** Opens a conversation in the reading pane, positioned at one of its messages: the one a child row
1688
+ * stands for, or the latest for a parent row. The thread pane loads the thread itself. */
1689
+ function handleOpenConversation(conversation: ConversationSummary, uid: string) {
1690
+ if (selectMode) {
1691
+ // Same rule as `handleSelect()` for a message row: while selecting, a row's own button ticks
1692
+ // the row rather than opening it.
1693
+ toggleConversationSelected(conversation);
1694
+ return;
1695
+ }
1696
+ if (isMobile) {
1697
+ // No dedicated mobile thread route yet - the existing single-message detail route already
1698
+ // handles any message uid regardless of conversation grouping.
1699
+ navigate(`/messages/${encodeURIComponent(uid)}`);
1700
+ return;
1701
+ }
1702
+ removedAnchorRef.current = null;
1703
+ setSelectedUid(uid);
1704
+ setOpenThread({ conversation, uid });
1705
+ }
1706
+
1707
+ // ---- Keyboard shortcuts (see `shared/keyboard`). Each is registered only while this view can do it, and calls what the toolbar and
1708
+ // ---- the selection bar call: the same bulk-action path (`runBulkAction()` - so the same optimistic badge tracking, the same rollback,
1709
+ // ---- the same lazily created Deleted Items folder), never a second implementation. Reply, Reply all, Forward, Archive and Move to are
1710
+ // ---- registered by the reading pane itself (`MessageDetailPane`'s `shortcuts`), which owns those handlers.
1711
+ const inConversations = preferences.showAsConversations;
1712
+ /** The selection bar isn't offered in the aggregate view (its rows belong to several mailboxes), and neither are these. */
1713
+ const keyboardActions = !aggregateFolderType;
1714
+ /** What the keyboard acts on exists: the ticked rows in select mode, else the message (or conversation) open in the reading pane. */
1715
+ const keyboardTargetExists = selectMode ? selectedMessages.length > 0 : inConversations ? openThread !== null : selected !== null;
1716
+ const rowCount = inConversations ? listedConversations.length : messages.length;
1717
+ const canMoveSelection = !selectMode && !isMobile && !loading && rowCount > 0;
1718
+
1719
+ function folderTypeOf(folderUidOfMessage: string): Folder["type"] | undefined {
1720
+ return currentFolders.find((f) => f.uid === folderUidOfMessage)?.type;
1721
+ }
1722
+
1723
+ /** Moves the keyboard's focus to a row - and scrolls it into view - once the selection has moved to it. */
1724
+ function focusRow(uid: string) {
1725
+ // Only called from a key press, when the list is on screen.
1726
+ const row = [...scrollContainerRef.current!.querySelectorAll<HTMLElement>("[data-message-uid]")].find(
1727
+ (element) => element.getAttribute("data-message-uid") === uid,
1728
+ );
1729
+ row?.scrollIntoView({ block: "nearest" });
1730
+ row?.querySelector<HTMLElement>("[data-row-open]")?.focus({ preventScroll: true });
1731
+ }
1732
+
1733
+ /** The index of the selected row: the open message's, or the open conversation's; -1 with nothing selected. */
1734
+ function selectedRowIndex(): number {
1735
+ if (inConversations) {
1736
+ return openThread ? listedConversations.findIndex((c) => c.conversationId === openThread.conversation.conversationId) : -1;
1737
+ }
1738
+ return messages.findIndex((m) => m.uid === selectedUid);
1739
+ }
1740
+
1741
+ function selectRow(index: number) {
1742
+ if (inConversations) {
1743
+ const conversation = listedConversations[index];
1744
+ handleOpenConversation(conversation, conversation.latestMessageUid);
1745
+ focusRow(conversation.latestMessageUid);
1746
+ } else {
1747
+ const message = messages[index];
1748
+ handleSelect(message);
1749
+ focusRow(message.uid);
1750
+ }
1751
+ }
1752
+
1753
+ function selectNeighbour(step: 1 | -1) {
1754
+ const current = selectedRowIndex();
1755
+ // After the selected message left the list (deleted, archived) the next row is the one that slid into its place.
1756
+ const anchor = removedAnchorRef.current;
1757
+ const target = current === -1 && anchor !== null ? (step === 1 ? anchor : anchor - 1) : current + step;
1758
+ selectRow(Math.min(rowCount - 1, Math.max(0, target)));
1759
+ }
1760
+
1761
+ function selectNextUnread(step: 1 | -1) {
1762
+ const isRowUnread = (index: number) => (inConversations ? listedConversations[index].unreadCount > 0 : isUnread(messages[index]));
1763
+ for (let index = selectedRowIndex() + step; index >= 0 && index < rowCount; index += step) {
1764
+ if (isRowUnread(index)) {
1765
+ selectRow(index);
1766
+ return;
1767
+ }
1768
+ }
1769
+ }
1770
+
1771
+ /** The messages of the open conversation that are in the folder being listed - fetched (once) like a ticked conversation's. */
1772
+ async function loadOpenConversation(conversation: ConversationSummary): Promise<Message[]> {
1773
+ const id = conversation.conversationId;
1774
+ let all = conversationMessagesById[id];
1775
+ if (!all) {
1776
+ all = await listConversationMessages(activeMailboxUid, id);
1777
+ const loaded = all;
1778
+ setConversationMessagesById((prev) => ({ ...prev, [id]: loaded }));
1779
+ }
1780
+ return all.filter((m) => !folderUid || m.folderUid === folderUid);
1781
+ }
1782
+
1783
+ /**
1784
+ * Runs a bulk action on what the keyboard acts on - the ticked rows in select mode, else the open message or conversation - keeping only
1785
+ * the messages `applies` accepts (Delete leaves out what is already in Deleted Items, Mark read what is already read: nothing to do
1786
+ * then). Held while another bulk action is on the wire, like the bar's buttons. Closes the open thread after a move away from it.
1787
+ */
1788
+ async function runOnKeyboardTargets(
1789
+ action: (chosen: Message[]) => Promise<Message[]>,
1790
+ removesRows: boolean,
1791
+ applies: (message: Message) => boolean = () => true,
1792
+ ) {
1793
+ if (bulkBusy || resolvingSelection > 0) {
1794
+ return;
1795
+ }
1796
+ let chosen: Message[];
1797
+ try {
1798
+ chosen = selectMode ? selectedMessages : inConversations ? await loadOpenConversation(openThread!.conversation) : [selected!];
1799
+ } catch (err) {
1800
+ notifyApiError(err, "Couldn't load the messages in that conversation");
1801
+ return;
1802
+ }
1803
+ chosen = chosen.filter(applies);
1804
+ if (chosen.length === 0) {
1805
+ return;
1806
+ }
1807
+ const succeeded = await runBulkAction(action, removesRows, chosen);
1808
+ if (succeeded && removesRows && inConversations && !selectMode) {
1809
+ setOpenThread(null);
1810
+ setSelectedUid(null);
1811
+ }
1812
+ }
1813
+
1814
+ useShortcut(SHORTCUTS.mail.next, () => selectNeighbour(1), { enabled: canMoveSelection });
1815
+ useShortcut(SHORTCUTS.mail.previous, () => selectNeighbour(-1), { enabled: canMoveSelection });
1816
+ useShortcut(SHORTCUTS.mail.nextUnread, () => selectNextUnread(1), { enabled: canMoveSelection });
1817
+ useShortcut(SHORTCUTS.mail.previousUnread, () => selectNextUnread(-1), { enabled: canMoveSelection });
1818
+ useShortcut(
1819
+ SHORTCUTS.mail.delete,
1820
+ () =>
1821
+ void runOnKeyboardTargets(
1822
+ async (chosen) => moveMessages(chosen, await resolveFolderOfType("deleted_items", "Deleted Items")),
1823
+ true,
1824
+ (message) => folderTypeOf(message.folderUid) !== "deleted_items",
1825
+ ),
1826
+ { enabled: keyboardActions && keyboardTargetExists },
1827
+ );
1828
+ useShortcut(
1829
+ SHORTCUTS.mail.markRead,
1830
+ () =>
1831
+ void runOnKeyboardTargets(
1832
+ (chosen) => setReadStateMany(chosen, true, { patch: patchListedMessage, track: trackMessageChange }),
1833
+ false,
1834
+ isUnread,
1835
+ ),
1836
+ { enabled: keyboardActions && keyboardTargetExists },
1837
+ );
1838
+ useShortcut(
1839
+ SHORTCUTS.mail.markUnread,
1840
+ () =>
1841
+ void runOnKeyboardTargets(
1842
+ (chosen) => setReadStateMany(chosen, false, { patch: patchListedMessage, track: trackMessageChange }),
1843
+ false,
1844
+ (message) => !isUnread(message),
1845
+ ),
1846
+ { enabled: keyboardActions && keyboardTargetExists },
1847
+ );
1848
+ useShortcut(
1849
+ SHORTCUTS.mail.flag,
1850
+ () =>
1851
+ // Flags them all unless every one already is - what the bar's Flag and Unflag would do on this selection.
1852
+ void runOnKeyboardTargets((chosen) => setMessagesFlagged(chosen, !chosen.every((m) => m.flags.flagged)), false),
1853
+ { enabled: keyboardActions && keyboardTargetExists },
1854
+ );
1855
+ useShortcut(
1856
+ SHORTCUTS.mail.open,
1857
+ (event) => {
1858
+ // Enter on a row that is not selected yet is the row button's own click (it selects). On the selected one - or with the focus
1859
+ // elsewhere - it opens the message on its own page, as it does in Outlook.
1860
+ const rowUid = (event.target as Element).closest("[data-message-uid]")?.getAttribute("data-message-uid");
1861
+ if (rowUid ? rowUid !== selectedUid : isActivatable(event.target)) {
1862
+ return false;
1863
+ }
1864
+ navigate(`/messages/${encodeURIComponent(selectedUid!)}`);
1865
+ },
1866
+ { enabled: !selectMode && selectedUid !== null },
1867
+ );
1868
+ useShortcut(
1869
+ SHORTCUTS.mail.close,
1870
+ () => {
1871
+ if (document.activeElement === searchInputRef.current && searchInput !== "") {
1872
+ setSearchInput("");
1873
+ } else if (selectMode) {
1874
+ leaveSelectMode();
1875
+ } else {
1876
+ setSelectedUid(null);
1877
+ setOpenThread(null);
1878
+ }
1879
+ },
1880
+ { enabled: selectMode || selectedUid !== null || openThread !== null || searchInput !== "" },
1881
+ );
1882
+ useShortcut(
1883
+ SHORTCUTS.mail.search,
1884
+ () => {
1885
+ searchInputRef.current?.focus();
1886
+ searchInputRef.current?.select();
1887
+ },
1888
+ { enabled: !inConversations && !aggregateFolderType },
1889
+ );
1890
+
1891
+ if (!folderUid && !aggregateFolderType) {
1892
+ // `MailShell` never renders this component at all until a mailbox is resolved (see its own
1893
+ // full-screen `MailboxProvisioning` takeover otherwise) — this is purely the brief gap before
1894
+ // that mailbox's own folder list has finished loading, not a "no mailbox" state. Distinct text
1895
+ // from the message list's own "Loading…" below — otherwise the two transient states become
1896
+ // indistinguishable to anything (a test, a user re-reading the screen) that catches this one.
1897
+ return <p className="p-8 text-sm text-text-muted">Loading your mailbox&hellip;</p>;
1898
+ }
1899
+
1900
+ return (
1901
+ <div className="flex h-full min-h-0">
1902
+ <div
1903
+ ref={scrollContainerRef}
1904
+ // Remembered as it moves (not when the list is left: by then React has already detached the element), so
1905
+ // the folder - or Mail, after another app - comes back where it was.
1906
+ onScroll={(event) => saveListScroll(listKey, event.currentTarget.scrollTop)}
1907
+ className="w-full md:w-96 shrink-0 md:border-r border-border overflow-y-auto"
1908
+ >
1909
+ {selectMode ? (
1910
+ <MailSelectionBar
1911
+ selected={selectedMessages}
1912
+ listed={messages}
1913
+ totals={
1914
+ preferences.showAsConversations
1915
+ ? { selected: selectedConversationIds.size, listed: conversations.length, noun: "conversation" }
1916
+ : undefined
1917
+ }
1918
+ onSelectAll={() =>
1919
+ preferences.showAsConversations
1920
+ ? selectAllConversations()
1921
+ : setSelectedUids(new Set(messages.map((m) => m.uid)))
1922
+ }
1923
+ onClearSelection={() =>
1924
+ preferences.showAsConversations ? setSelectedConversationIds(new Set()) : setSelectedUids(new Set())
1925
+ }
1926
+ onCancel={leaveSelectMode}
1927
+ folders={currentFolders}
1928
+ currentFolderUid={folderUid}
1929
+ labels={mailboxLabels}
1930
+ mailboxUid={activeMailboxUid}
1931
+ onLabelCreated={(label) => setMailboxLabels((prev) => [...prev, label])}
1932
+ onFolderCreated={onFolderCreated}
1933
+ onApplyLabels={applyLabelsToSelection}
1934
+ onSetRead={(read) =>
1935
+ void runBulkAction(
1936
+ // Optimistic: the rows and the folder badges change now, and change back if the server refuses.
1937
+ (chosen) => setReadStateMany(chosen, read, { patch: patchListedMessage, track: trackMessageChange }),
1938
+ false,
1939
+ )
1940
+ }
1941
+ onSetFlagged={(flagged) => void runBulkAction((chosen) => setMessagesFlagged(chosen, flagged), false)}
1942
+ shortcuts={keyboardActions}
1943
+ onArchive={() => void runBulkAction(bulkArchive, true)}
1944
+ onMoveTo={async (targetFolderUid) => {
1945
+ await runBulkAction((chosen) => moveMessages(chosen, targetFolderUid), true);
1946
+ }}
1947
+ onReportJunk={() => void moveSelectionToType("junk", "Junk Email")}
1948
+ onDelete={() => void moveSelectionToType("deleted_items", "Deleted Items")}
1949
+ busy={bulkBusy || resolvingSelection > 0}
1950
+ />
1951
+ ) : (
1952
+ <MailListToolbar
1953
+ sortBy={preferences.sortBy}
1954
+ sortOrder={preferences.sortOrder}
1955
+ filter={preferences.filter}
1956
+ labelUids={preferences.labelUids}
1957
+ labels={mailboxLabels}
1958
+ mailboxUid={activeMailboxUid}
1959
+ onLabelCreated={(label) => setMailboxLabels((prev) => [...prev, label])}
1960
+ onLabelUidsChange={(nextLabelUids) => updatePreferences({ labelUids: nextLabelUids })}
1961
+ showAsConversations={preferences.showAsConversations}
1962
+ onSortChange={(sortBy, sortOrder) => updatePreferences({ sortBy, sortOrder })}
1963
+ onFilterChange={(filter) => updatePreferences({ filter })}
1964
+ onShowAsConversationsChange={(showAsConversations) => updatePreferences({ showAsConversations })}
1965
+ selectMode={selectMode}
1966
+ onSelectModeChange={setSelectMode}
1967
+ offerClassificationFilters={offerClassificationFilters}
1968
+ filterDisabled={isSearching}
1969
+ filterDisabledReason="Filters don't apply to search results"
1970
+ sortKeysDisabled={isSearching || !!aggregateFolderType}
1971
+ // A conversation row is a thread summary, so only some of the keys have anything to
1972
+ // order by - the rest stay pickable and are applied to the rows already fetched.
1973
+ unavailableSortKeys={preferences.showAsConversations ? CONVERSATION_SORT_UNAVAILABLE : undefined}
1974
+ sortKeysNote={
1975
+ isSearching
1976
+ ? "Search results are ranked by relevance rather than sorted."
1977
+ : aggregateFolderType
1978
+ ? "This view merges the newest mail from every mailbox and is always listed by date."
1979
+ : preferences.showAsConversations
1980
+ ? CONVERSATION_SORT_NOTE
1981
+ : undefined
1982
+ }
1983
+ selectDisabled={!!aggregateFolderType || loading || listedRowCount === 0}
1984
+ selectDisabledReason={
1985
+ aggregateFolderType
1986
+ ? "Open a mailbox's own folder to select messages"
1987
+ : loading
1988
+ ? "Wait for this folder to finish loading"
1989
+ : "There is nothing here to select"
1990
+ }
1991
+ />
1992
+ )}
1993
+ {!preferences.showAsConversations && (
1994
+ <div className="p-2 border-b border-border">
1995
+ <input
1996
+ ref={searchInputRef}
1997
+ type="search"
1998
+ value={searchInput}
1999
+ onChange={(e) => setSearchInput(e.target.value)}
2000
+ placeholder={aggregateFolderType ? "Open a mailbox's own folder to search" : "Search all mail…"}
2001
+ aria-label="Search all mail"
2002
+ disabled={!!aggregateFolderType}
2003
+ className="w-full text-sm px-3 py-1.5 rounded-md border border-border bg-surface disabled:opacity-55"
2004
+ />
2005
+ </div>
2006
+ )}
2007
+ {/* Two tabs, as Outlook has: Focused and Other. There is no "All" tab - the whole Inbox is
2008
+ still one pick away, in the Filter menu, which is where every other named filter lives
2009
+ and the only place that can show which of them is really in force. A stored `all` (or
2010
+ Unread, Flagged, ...) therefore still lists what it always did, with neither tab
2011
+ pressed, rather than being migrated into one of these two halves behind the reader's
2012
+ back - see `MAIL_LIST_FILTERS`, which still offers it. */}
2013
+ {offerClassificationFilters && (
2014
+ <div className="flex border-b border-border text-xs">
2015
+ {MAIL_LIST_CLASSIFICATION_FILTERS.map(({ value, label }) => (
2016
+ <button
2017
+ key={value}
2018
+ type="button"
2019
+ aria-pressed={preferences.filter === value}
2020
+ onClick={() => updatePreferences({ filter: value })}
2021
+ className={[
2022
+ "flex-1 py-1.5 font-semibold",
2023
+ preferences.filter === value
2024
+ ? "text-primary-dark border-b-2 border-primary-dark"
2025
+ : "text-text-muted",
2026
+ ].join(" ")}
2027
+ >
2028
+ {label}
2029
+ </button>
2030
+ ))}
2031
+ </div>
2032
+ )}
2033
+
2034
+ {isSearching && (
2035
+ <div className="px-4 py-1.5 text-xs text-text-muted border-b border-border flex items-center justify-between gap-2">
2036
+ {/* §_Progressive Results_: "Never show a hard count until every tier has reported.
2037
+ Display n of ??, or omit the count. A settled count is the signal that ordering
2038
+ is final." */}
2039
+ <span>
2040
+ {tier1Done && tier2Done && tier3Done
2041
+ ? `${messages.length} result${messages.length === 1 ? "" : "s"}`
2042
+ : `${messages.length} of ??`}
2043
+ </span>
2044
+ {tier2Done && !searchAllMail && (
2045
+ <button
2046
+ type="button"
2047
+ onClick={handleSearchAllMail}
2048
+ className="text-primary-dark hover:underline font-medium shrink-0"
2049
+ >
2050
+ Search all mail
2051
+ </button>
2052
+ )}
2053
+ </div>
2054
+ )}
2055
+ {isSearching && coverage?.indexedFrom && (
2056
+ <div className="px-4 py-1.5 text-xs text-text-muted border-b border-border">
2057
+ Local search covers messages back to {new Date(coverage.indexedFrom).toLocaleDateString()}
2058
+ {coverage.building ? " (still building)" : ""}
2059
+ {searchAllMail
2060
+ ? " - searching everything, not just recent mail."
2061
+ : " - older encrypted mail is still searched, just slower."}
2062
+ </div>
2063
+ )}
2064
+ {isSearching && !getUnlockedKeys(mailboxUid!) && (
2065
+ <div className="px-4 py-2 border-b border-border bg-surface-alt">
2066
+ <button
2067
+ type="button"
2068
+ onClick={handleUnlockSearch}
2069
+ className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
2070
+ >
2071
+ <HiOutlineLockClosed size={12} aria-hidden="true" />
2072
+ Unlock to include encrypted messages in these results
2073
+ </button>
2074
+ </div>
2075
+ )}
2076
+ {!isSearching && !preferences.showAsConversations && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (
2077
+ <div className="px-4 py-2 border-b border-border bg-surface-alt">
2078
+ <button
2079
+ type="button"
2080
+ onClick={handleUnlockList}
2081
+ className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
2082
+ >
2083
+ <HiOutlineLockClosed size={12} aria-hidden="true" />
2084
+ Unlock to show {undecryptedEncryptedUids.length === 1 ? "an encrypted message's" : "encrypted messages'"} subject
2085
+ </button>
2086
+ </div>
2087
+ )}
2088
+
2089
+ {error && (
2090
+ <div className="p-4">
2091
+ <Alert>{error}</Alert>
2092
+ </div>
2093
+ )}
2094
+
2095
+ {loading ? (
2096
+ // A skeleton of rows rather than a line of text: the list keeps its shape while a folder that was not shown a
2097
+ // moment ago loads (one that was is shown at once, from its snapshot).
2098
+ <div role="status" aria-busy="true" className="p-4">
2099
+ <span className="sr-only">Loading&hellip;</span>
2100
+ <SkeletonList count={8} />
2101
+ </div>
2102
+ ) : preferences.showAsConversations ? (
2103
+ <>
2104
+ <ConversationList
2105
+ conversations={listedConversations}
2106
+ newestFirst={preferences.sortBy === "date" && preferences.sortOrder === "desc"}
2107
+ mailboxUid={activeMailboxUid}
2108
+ selectedUid={selectedUid}
2109
+ messageOverrides={conversationPatches}
2110
+ onOpenMessage={handleOpenConversation}
2111
+ selectMode={selectMode}
2112
+ selectedConversationIds={selectedConversationIds}
2113
+ onToggleSelected={toggleConversationSelected}
2114
+ />
2115
+ {hasMore && (
2116
+ <div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
2117
+ {loadMoreStatus}
2118
+ </div>
2119
+ )}
2120
+ </>
2121
+ ) : messages.length === 0 ? (
2122
+ <>
2123
+ <p className="p-4 text-sm text-text-muted">
2124
+ {isSearching
2125
+ ? `No messages match "${searchQuery}".`
2126
+ : effectiveFilter === "all"
2127
+ ? "No messages in this folder."
2128
+ : "No messages here."}
2129
+ </p>
2130
+ {/* Still offered while a filter hides every loaded row - what it's looking for may be
2131
+ on a later page. */}
2132
+ {hasMore && (
2133
+ <div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
2134
+ {loadMoreStatus}
2135
+ </div>
2136
+ )}
2137
+ </>
2138
+ ) : (
2139
+ <>
2140
+ <ul>
2141
+ {messages.map((message) => (
2142
+ <li
2143
+ key={message.uid}
2144
+ data-message-uid={message.uid}
2145
+ data-unread={isUnread(message) ? "true" : undefined}
2146
+ className={rowClass(
2147
+ { unread: isUnread(message), selected: message.uid === selectedUid || selectedUids.has(message.uid) },
2148
+ "flex items-stretch",
2149
+ )}
2150
+ >
2151
+ <UnreadBar unread={isUnread(message)} />
2152
+ {selectMode && (
2153
+ <span className="shrink-0 flex items-center pl-3">
2154
+ <input
2155
+ type="checkbox"
2156
+ checked={selectedUids.has(message.uid)}
2157
+ onChange={() => toggleSelected(message.uid)}
2158
+ aria-label={`Select ${rowSubject(message)}`}
2159
+ className="w-4 h-4 accent-primary"
2160
+ />
2161
+ </span>
2162
+ )}
2163
+ <button
2164
+ type="button"
2165
+ data-row-open
2166
+ onClick={() => handleSelect(message)}
2167
+ className={["flex-1 min-w-0 text-left px-4 py-3", ROW_FOCUS_CLASS].join(" ")}
2168
+ >
2169
+ <UnreadLabel unread={isUnread(message)} />
2170
+ <div className="flex items-center justify-between gap-2 text-sm">
2171
+ <MailAddress recipient={message.from} className={senderClass(isUnread(message))} />
2172
+ <span className={["text-xs shrink-0", dateClass(isUnread(message))].join(" ")}>
2173
+ {new Date(message.receivedDate).toLocaleDateString()}
2174
+ </span>
2175
+ </div>
2176
+ {aggregateFolderType && (
2177
+ // The one view where a row needs to say which mailbox it came from.
2178
+ <div className="text-xs text-text-muted truncate font-normal">
2179
+ {mailboxes.find((mb) => mb.uid === message.mailboxUid)?.displayName}
2180
+ </div>
2181
+ )}
2182
+ {isSearching && pendingUids.has(message.uid) ? (
2183
+ // §_Progressive Results_: "Unresolved encrypted results MUST
2184
+ // be rendered as skeleton entries in place, not appended on
2185
+ // arrival." This uid is a Tier 1 metadataOnly guess Tier 2/3
2186
+ // haven't confirmed (or ruled out) yet.
2187
+ <div className="flex flex-col gap-1.5 py-0.5">
2188
+ <Skeleton height="h-3.5" className="w-2/3 rounded-sm" />
2189
+ <Skeleton height="h-3" className="w-full rounded-sm" />
2190
+ </div>
2191
+ ) : (
2192
+ <>
2193
+ <div className={["text-sm truncate", subjectClass(isUnread(message))].join(" ")}>
2194
+ {rowSubject(message)}
2195
+ </div>
2196
+ <div className="flex items-center gap-2 text-xs text-text-muted font-normal">
2197
+ <span className="truncate">
2198
+ {snippets[message.uid] ||
2199
+ decryptedRows[message.uid]?.preview ||
2200
+ message.bodyPreview ||
2201
+ (message.encrypted ? <EncryptedPreview /> : null)}
2202
+ </span>
2203
+ {message.hasAttachments && <HiOutlinePaperClip size={12} aria-label="Has attachments" />}
2204
+ {message.flags.flagged && (
2205
+ <HiOutlineFlag size={12} aria-label="Flagged" className="text-danger" />
2206
+ )}
2207
+ </div>
2208
+ {/* What the server is doing with a message in Outbox: sending, retrying, or why it wasn't sent. */}
2209
+ {isOutbox && <OutboxRowStatus message={message} />}
2210
+ </>
2211
+ )}
2212
+ </button>
2213
+ </li>
2214
+ ))}
2215
+ </ul>
2216
+ {hasMore && (
2217
+ <div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
2218
+ {loadMoreStatus}
2219
+ </div>
2220
+ )}
2221
+ {aggregateFolderType && (
2222
+ <p className="p-4 text-center text-xs text-text-muted">
2223
+ Showing the most recent mail from each mailbox. Open a specific mailbox&rsquo;s folder to
2224
+ see older mail.
2225
+ </p>
2226
+ )}
2227
+ </>
2228
+ )}
2229
+ </div>
2230
+ {/* The reading pane's own height: a row of the full-height mail view, stretched to it by the
2231
+ flex chain rather than by a percentage (an explicit height would opt it out of that
2232
+ stretching), with `min-h-0` so a long message scrolls inside it instead of pushing it past
2233
+ the window. Everything below - the thread pane, each message's `MessageDetailPane`, the
2234
+ body iframe that cannot measure itself - takes its height from here, never from a `vh`
2235
+ number of its own. */}
2236
+ <div className="hidden md:flex flex-1 min-w-0 min-h-0">
2237
+ {preferences.showAsConversations ? (
2238
+ <LazyConversationThreadPane
2239
+ conversation={openThread?.conversation ?? null}
2240
+ selectedUid={openThread?.uid ?? null}
2241
+ mailboxUid={activeMailboxUid}
2242
+ folders={currentFolders}
2243
+ labels={mailboxLabels}
2244
+ shortcuts={!selectMode}
2245
+ onMessagePatched={patchListedMessage}
2246
+ onMessageRemoved={(updated) => removeListedMessage(updated.uid)}
2247
+ onLabelCreated={(label) => setMailboxLabels((prev) => [...prev, label])}
2248
+ onFolderCreated={onFolderCreated}
2249
+ />
2250
+ ) : (
2251
+ <LazyMessageDetailPane
2252
+ shortcuts={!selectMode}
2253
+ message={selected}
2254
+ attachments={attachments}
2255
+ isSentItems={isSentItems}
2256
+ onRecalled={patchListedMessage}
2257
+ isOutbox={isOutbox}
2258
+ onReceiptHandled={patchListedMessage}
2259
+ draftsFolderUid={draftsFolderUid}
2260
+ folders={folders}
2261
+ onMoved={(updated) => {
2262
+ // Same reasoning as onArchived below - a move takes the message out of the folder
2263
+ // being listed, so it leaves the list rather than being patched in place.
2264
+ removeListedMessage(updated.uid);
2265
+ }}
2266
+ onFolderCreated={onFolderCreated}
2267
+ onScheduledSendCanceled={(updated) => {
2268
+ // The message moved out of the currently-viewed Outbox folder (into Drafts)
2269
+ // - unlike a recall, which patches a message in place, this removes it from
2270
+ // the list entirely, matching what a real folder switch would show.
2271
+ removeListedMessage(updated.uid);
2272
+ }}
2273
+ onArchived={(updated) => {
2274
+ // Same reasoning as onScheduledSendCanceled above - the message moved out of
2275
+ // whichever folder is currently being viewed (into Archive), so it's removed
2276
+ // from the list rather than patched in place.
2277
+ removeListedMessage(updated.uid);
2278
+ }}
2279
+ labels={labels}
2280
+ onLabelsChanged={patchListedMessage}
2281
+ onLabelCreated={(label) =>
2282
+ (otherMailbox ? setOtherMailboxLabels : setMailboxLabels)((prev) => [...prev, label])
2283
+ }
2284
+ />
2285
+ )}
2286
+ </div>
2287
+ </div>
2288
+ );
2289
+ }
2290
+
2291
+ export default routedPage("/", InboxPage);