@rapidmx/web-client 0.4.0 → 0.6.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 (320) hide show
  1. package/README.md +88 -0
  2. package/apps/admin/audit-log/index.tsx +35 -8
  3. package/apps/admin/data-requests/index.tsx +480 -485
  4. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  5. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  6. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  7. package/apps/admin/index.tsx +19 -1
  8. package/apps/admin/ingest-queue/index.tsx +44 -5
  9. package/apps/admin/mailboxes/[uid].tsx +211 -184
  10. package/apps/admin/quarantine/index.tsx +46 -14
  11. package/apps/admin/transport-rules/[uid].tsx +30 -1
  12. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  13. package/apps/admin/transport-rules/index.tsx +24 -6
  14. package/apps/admin/transport-rules/new/index.tsx +30 -1
  15. package/apps/escrow/_layout.tsx +2 -3
  16. package/apps/escrow/audit-log/index.tsx +196 -168
  17. package/apps/escrow/matters/[uid].tsx +621 -521
  18. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  19. package/apps/shared/components/admin/layout/AdminShell.tsx +28 -9
  20. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  21. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  22. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  23. package/apps/shared/components/admin/settings/BrandingForm.tsx +15 -4
  24. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +18 -7
  25. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +10 -2
  26. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +6 -0
  27. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +45 -12
  28. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -595
  29. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -98
  30. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +49 -13
  31. package/apps/shared/components/admin/setup/SetupWizard.tsx +150 -34
  32. package/apps/shared/components/admin/signOut.ts +79 -0
  33. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  34. package/apps/shared/components/calendar/EventModal.tsx +717 -549
  35. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  36. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  37. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  38. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  39. package/apps/shared/components/calendar/allDay.ts +124 -0
  40. package/apps/shared/components/calendar/layout/CalendarShell.tsx +194 -192
  41. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  42. package/apps/shared/components/contacts/ContactForm.tsx +377 -348
  43. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  44. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  45. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  46. package/apps/shared/components/contacts/layout/ContactsShell.tsx +199 -197
  47. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  48. package/apps/shared/components/layout/AppShell.tsx +123 -33
  49. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  50. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +389 -298
  51. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  52. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  53. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -128
  54. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  55. package/apps/shared/components/mail/MessageDetailPane.tsx +1318 -630
  56. package/apps/shared/components/mail/compose/ComposeContext.tsx +20 -15
  57. package/apps/shared/components/mail/compose/ComposeWindow.tsx +854 -132
  58. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  59. package/apps/shared/components/mail/layout/MailShell.tsx +420 -408
  60. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  61. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  62. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  63. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  64. package/apps/shared/components/settings/layout/SettingsShell.tsx +23 -9
  65. package/apps/shared/components/tasks/layout/TasksShell.tsx +201 -199
  66. package/apps/shared/mail/listAllPages.ts +39 -0
  67. package/apps/shared/plugins/pluginNav.ts +73 -0
  68. package/apps/shared/search/LocalIndexLifecycle.tsx +64 -55
  69. package/apps/shared/search/localIndexBuilder.ts +481 -179
  70. package/apps/shared/search/localIndexRpcClient.ts +231 -29
  71. package/apps/shared/search/localIndexSchema.ts +15 -6
  72. package/apps/shared/search/localIndexStorage.ts +98 -0
  73. package/apps/shared/search/localIndexVFS.ts +32 -1
  74. package/apps/shared/search/localIndexWorker.ts +648 -186
  75. package/apps/www/calendar/index.tsx +438 -417
  76. package/apps/www/contacts/[uid].tsx +21 -2
  77. package/apps/www/contacts/index.tsx +116 -12
  78. package/apps/www/index.tsx +1314 -1041
  79. package/apps/www/messages/[uid].tsx +18 -6
  80. package/apps/www/settings/auto-reply/index.tsx +134 -129
  81. package/apps/www/settings/encryption/index.tsx +739 -216
  82. package/apps/www/settings/filters/[uid].tsx +171 -155
  83. package/apps/www/settings/filters/new/index.tsx +8 -1
  84. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  85. package/apps/www/settings/privacy/index.tsx +494 -436
  86. package/apps/www/settings/read-receipts/index.tsx +148 -125
  87. package/apps/www/settings/sharing/index.tsx +17 -14
  88. package/apps/www/tasks/index.tsx +638 -579
  89. package/dist/apps/admin/_404.d.ts +2 -0
  90. package/dist/apps/admin/_500.d.ts +2 -0
  91. package/dist/apps/admin/_layout.d.ts +8 -0
  92. package/dist/apps/admin/audit-log/index.d.ts +11 -0
  93. package/dist/apps/admin/audit-log/index.js +38 -8
  94. package/dist/apps/admin/branding/index.d.ts +3 -0
  95. package/dist/apps/admin/data-requests/index.d.ts +3 -0
  96. package/dist/apps/admin/data-requests/index.js +52 -72
  97. package/dist/apps/admin/distribution-lists/[uid].d.ts +7 -0
  98. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  99. package/dist/apps/admin/distribution-lists/index.d.ts +3 -0
  100. package/dist/apps/admin/distribution-lists/new/index.d.ts +3 -0
  101. package/dist/apps/admin/domains/[uid].d.ts +7 -0
  102. package/dist/apps/admin/domains/index.d.ts +3 -0
  103. package/dist/apps/admin/domains/new/index.d.ts +3 -0
  104. package/dist/apps/admin/encryption-policy/index.d.ts +3 -0
  105. package/dist/apps/admin/escrow-scopes/[uid].d.ts +21 -0
  106. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  107. package/dist/apps/admin/escrow-scopes/index.d.ts +3 -0
  108. package/dist/apps/admin/escrow-scopes/new/index.d.ts +3 -0
  109. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  110. package/dist/apps/admin/index.d.ts +3 -0
  111. package/dist/apps/admin/index.js +4 -1
  112. package/dist/apps/admin/ingest-queue/index.d.ts +4 -0
  113. package/dist/apps/admin/ingest-queue/index.js +24 -6
  114. package/dist/apps/admin/mailbox-policy/index.d.ts +3 -0
  115. package/dist/apps/admin/mailboxes/[uid].d.ts +7 -0
  116. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  117. package/dist/apps/admin/mailboxes/new/index.d.ts +3 -0
  118. package/dist/apps/admin/plugins/index.d.ts +3 -0
  119. package/dist/apps/admin/quarantine/index.d.ts +6 -0
  120. package/dist/apps/admin/quarantine/index.js +20 -11
  121. package/dist/apps/admin/retention-policy/index.d.ts +3 -0
  122. package/dist/apps/admin/setup/index.d.ts +3 -0
  123. package/dist/apps/admin/transport-rules/[uid].d.ts +7 -0
  124. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  125. package/dist/apps/admin/transport-rules/_transportRuleConfig.d.ts +21 -0
  126. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  127. package/dist/apps/admin/transport-rules/index.d.ts +3 -0
  128. package/dist/apps/admin/transport-rules/index.js +22 -6
  129. package/dist/apps/admin/transport-rules/new/index.d.ts +3 -0
  130. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  131. package/dist/apps/escrow/_layout.d.ts +10 -0
  132. package/dist/apps/escrow/_layout.js +3 -3
  133. package/dist/apps/escrow/audit-log/index.d.ts +9 -0
  134. package/dist/apps/escrow/audit-log/index.js +26 -1
  135. package/dist/apps/escrow/index.d.ts +3 -0
  136. package/dist/apps/escrow/matters/[uid].d.ts +7 -0
  137. package/dist/apps/escrow/matters/[uid].js +77 -42
  138. package/dist/apps/escrow/matters/new/index.d.ts +3 -0
  139. package/dist/apps/shared/components/admin/distributionLists/MemberListCard.d.ts +17 -0
  140. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.d.ts +32 -0
  141. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  142. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +32 -0
  143. package/dist/apps/shared/components/admin/layout/AdminShell.js +15 -6
  144. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.d.ts +12 -0
  145. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  146. package/dist/apps/shared/components/admin/mailboxes/MailboxTable.d.ts +6 -0
  147. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.d.ts +19 -0
  148. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  149. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +12 -0
  150. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  151. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +9 -0
  152. package/dist/apps/shared/components/admin/settings/BrandingForm.js +15 -4
  153. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.d.ts +12 -0
  154. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +13 -7
  155. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.d.ts +12 -0
  156. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +7 -2
  157. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.d.ts +9 -0
  158. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.d.ts +19 -0
  159. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +6 -0
  160. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +10 -0
  161. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +34 -12
  162. package/dist/apps/shared/components/admin/settings/PluginsManager.d.ts +4 -0
  163. package/dist/apps/shared/components/admin/settings/PluginsManager.js +390 -78
  164. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.d.ts +9 -0
  165. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +40 -11
  166. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.d.ts +14 -0
  167. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +29 -13
  168. package/dist/apps/shared/components/admin/setup/SetupWizard.d.ts +36 -0
  169. package/dist/apps/shared/components/admin/setup/SetupWizard.js +87 -21
  170. package/dist/apps/shared/components/admin/signOut.d.ts +23 -0
  171. package/dist/apps/shared/components/admin/signOut.js +75 -0
  172. package/dist/apps/shared/components/admin/usePagedList.d.ts +39 -0
  173. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  174. package/dist/apps/shared/components/calendar/CalendarListSidebar.d.ts +26 -0
  175. package/dist/apps/shared/components/calendar/EventModal.d.ts +46 -0
  176. package/dist/apps/shared/components/calendar/EventModal.js +142 -21
  177. package/dist/apps/shared/components/calendar/MonthView.d.ts +15 -0
  178. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  179. package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +20 -0
  180. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  181. package/dist/apps/shared/components/calendar/ResourcePicker.d.ts +19 -0
  182. package/dist/apps/shared/components/calendar/SplitDayView.d.ts +28 -0
  183. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  184. package/dist/apps/shared/components/calendar/TimeGridView.d.ts +17 -0
  185. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  186. package/dist/apps/shared/components/calendar/allDay.d.ts +51 -0
  187. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  188. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +40 -0
  189. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +3 -3
  190. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +23 -0
  191. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  192. package/dist/apps/shared/components/contacts/ContactForm.d.ts +20 -0
  193. package/dist/apps/shared/components/contacts/ContactForm.js +32 -10
  194. package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +40 -0
  195. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  196. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +25 -0
  197. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +40 -0
  198. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  199. package/dist/apps/shared/components/contacts/contactKeys.d.ts +29 -0
  200. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  201. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +21 -0
  202. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +2 -2
  203. package/dist/apps/shared/components/escrow/layout/EscrowShell.d.ts +25 -0
  204. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  205. package/dist/apps/shared/components/forms/StringListField.d.ts +22 -0
  206. package/dist/apps/shared/components/layout/AppShell.d.ts +49 -0
  207. package/dist/apps/shared/components/layout/AppShell.js +97 -26
  208. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +18 -0
  209. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  210. package/dist/apps/shared/components/layout/KeyEnrollmentGate.d.ts +51 -0
  211. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +49 -12
  212. package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +13 -0
  213. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  214. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.d.ts +66 -0
  215. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  216. package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +45 -0
  217. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +73 -14
  218. package/dist/apps/shared/components/layout/UserMenu.d.ts +23 -0
  219. package/dist/apps/shared/components/mail/ConversationList.d.ts +11 -0
  220. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +23 -0
  221. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  222. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +93 -0
  223. package/dist/apps/shared/components/mail/MessageDetailPane.js +441 -36
  224. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +58 -0
  225. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  226. package/dist/apps/shared/components/mail/compose/ComposeToolbar.d.ts +15 -0
  227. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +37 -0
  228. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +699 -128
  229. package/dist/apps/shared/components/mail/compose/EmojiPicker.d.ts +14 -0
  230. package/dist/apps/shared/components/mail/compose/GifPicker.d.ts +13 -0
  231. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +34 -0
  232. package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.d.ts +16 -0
  233. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.d.ts +27 -0
  234. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  235. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +57 -0
  236. package/dist/apps/shared/components/mail/layout/MailShell.js +13 -4
  237. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +33 -0
  238. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  239. package/dist/apps/shared/components/mail/verificationSeals.d.ts +22 -0
  240. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  241. package/dist/apps/shared/components/mail/writableMailboxes.d.ts +15 -0
  242. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  243. package/dist/apps/shared/components/rules/RuleBuilder.d.ts +70 -0
  244. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  245. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +41 -0
  246. package/dist/apps/shared/components/settings/layout/SettingsShell.js +19 -9
  247. package/dist/apps/shared/components/tasks/TasksSidebar.d.ts +41 -0
  248. package/dist/apps/shared/components/tasks/TasksToolbar.d.ts +14 -0
  249. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +24 -0
  250. package/dist/apps/shared/components/tasks/layout/TasksShell.js +2 -2
  251. package/dist/apps/shared/mail/findWellKnownFolderUid.d.ts +5 -0
  252. package/dist/apps/shared/mail/listAllPages.d.ts +17 -0
  253. package/dist/apps/shared/mail/listAllPages.js +25 -0
  254. package/dist/apps/shared/plugins/pluginNav.d.ts +39 -0
  255. package/dist/apps/shared/plugins/pluginNav.js +33 -0
  256. package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +13 -0
  257. package/dist/apps/shared/search/LocalIndexLifecycle.js +58 -54
  258. package/dist/apps/shared/search/localIndexBlockCipher.d.ts +36 -0
  259. package/dist/apps/shared/search/localIndexBuilder.d.ts +85 -0
  260. package/dist/apps/shared/search/localIndexBuilder.js +292 -37
  261. package/dist/apps/shared/search/localIndexKey.d.ts +4 -0
  262. package/dist/apps/shared/search/localIndexRpcClient.d.ts +71 -0
  263. package/dist/apps/shared/search/localIndexRpcClient.js +191 -26
  264. package/dist/apps/shared/search/localIndexSchema.d.ts +111 -0
  265. package/dist/apps/shared/search/localIndexSchema.js +12 -6
  266. package/dist/apps/shared/search/localIndexSizePreference.d.ts +27 -0
  267. package/dist/apps/shared/search/localIndexStorage.d.ts +28 -0
  268. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  269. package/dist/apps/shared/search/localIndexVFS.d.ts +101 -0
  270. package/dist/apps/shared/search/localIndexVFS.js +68 -40
  271. package/dist/apps/shared/search/localIndexWorker.d.ts +145 -0
  272. package/dist/apps/shared/search/localIndexWorker.js +519 -173
  273. package/dist/apps/shared/search/searchTier2.d.ts +30 -0
  274. package/dist/apps/www/_404.d.ts +2 -0
  275. package/dist/apps/www/_500.d.ts +2 -0
  276. package/dist/apps/www/_layout.d.ts +14 -0
  277. package/dist/apps/www/calendar/index.d.ts +3 -0
  278. package/dist/apps/www/calendar/index.js +28 -10
  279. package/dist/apps/www/contacts/[uid].d.ts +13 -0
  280. package/dist/apps/www/contacts/[uid].js +13 -2
  281. package/dist/apps/www/contacts/index.d.ts +3 -0
  282. package/dist/apps/www/contacts/index.js +80 -10
  283. package/dist/apps/www/index.d.ts +3 -0
  284. package/dist/apps/www/index.js +339 -108
  285. package/dist/apps/www/messages/[uid].d.ts +11 -0
  286. package/dist/apps/www/messages/[uid].js +18 -6
  287. package/dist/apps/www/settings/auto-reply/index.d.ts +4 -0
  288. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  289. package/dist/apps/www/settings/encryption/index.d.ts +7 -0
  290. package/dist/apps/www/settings/encryption/index.js +514 -97
  291. package/dist/apps/www/settings/filters/[uid].d.ts +8 -0
  292. package/dist/apps/www/settings/filters/[uid].js +22 -7
  293. package/dist/apps/www/settings/filters/_mailFilterRuleConfig.d.ts +11 -0
  294. package/dist/apps/www/settings/filters/index.d.ts +4 -0
  295. package/dist/apps/www/settings/filters/new/index.d.ts +4 -0
  296. package/dist/apps/www/settings/filters/new/index.js +8 -2
  297. package/dist/apps/www/settings/focused-inbox/index.d.ts +4 -0
  298. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  299. package/dist/apps/www/settings/labels/index.d.ts +4 -0
  300. package/dist/apps/www/settings/privacy/index.d.ts +4 -0
  301. package/dist/apps/www/settings/privacy/index.js +57 -22
  302. package/dist/apps/www/settings/read-receipts/index.d.ts +4 -0
  303. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  304. package/dist/apps/www/settings/sharing/index.d.ts +4 -0
  305. package/dist/apps/www/settings/sharing/index.js +7 -11
  306. package/dist/apps/www/settings/signatures/[uid].d.ts +8 -0
  307. package/dist/apps/www/settings/signatures/index.d.ts +4 -0
  308. package/dist/apps/www/settings/signatures/new/index.d.ts +4 -0
  309. package/dist/apps/www/settings/signatures/signatureDefaults.d.ts +10 -0
  310. package/dist/apps/www/tasks/index.d.ts +3 -0
  311. package/dist/apps/www/tasks/index.js +50 -10
  312. package/package.json +3 -3
  313. package/apps/shared/components/booking/AvailabilityEditor.tsx +0 -118
  314. package/apps/www/settings/booking-types/[uid].tsx +0 -300
  315. package/apps/www/settings/booking-types/index.tsx +0 -98
  316. package/apps/www/settings/booking-types/new/index.tsx +0 -212
  317. package/dist/apps/shared/components/booking/AvailabilityEditor.js +0 -45
  318. package/dist/apps/www/settings/booking-types/[uid].js +0 -129
  319. package/dist/apps/www/settings/booking-types/index.js +0 -29
  320. package/dist/apps/www/settings/booking-types/new/index.js +0 -86
@@ -1,1041 +1,1314 @@
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 { HiOutlineLockClosed } from "react-icons/hi2";
7
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
- import { Mailbox, Message, MessageClassification, getMessage, getMessageRawContent, listMessages } from "@rapidmx/react-shared/mail/mailApi.js";
9
- import { Label, listLabels } from "@rapidmx/react-shared/mail/labelsApi.js";
10
- import { ConversationSummary, listConversations } from "@rapidmx/react-shared/mail/conversationsApi.js";
11
- import { SearchResult, search as searchMailbox } from "@rapidmx/react-shared/search/searchApi.js";
12
- import { parseSearchQuery, type ParsedSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
13
- import { normalizeServerScores } from "@rapidmx/react-shared/search/searchScoring.js";
14
- import { searchEncryptedCandidates } from "@rapidmx/react-shared/search/searchTier3.js";
15
- import { searchLocalIndex } from "../shared/search/searchTier2.js";
16
- import type { Coverage } from "../shared/search/localIndexWorker.js";
17
- import { getUnlockedKeys, UnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
18
- import { evaluateMessageSecurity } from "@rapidmx/react-shared/crypto/messageSecurity.js";
19
- import { useMarkMessageRead, useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
20
- import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
21
- import MailShell, {
22
- AggregateFolderType,
23
- MailboxFolders,
24
- MailShellProps,
25
- useMailShell,
26
- } from "../shared/components/mail/layout/MailShell.js";
27
- import MessageDetailPane from "../shared/components/mail/MessageDetailPane.js";
28
- import ConversationList from "../shared/components/mail/ConversationList.js";
29
- import ConversationThreadPane from "../shared/components/mail/ConversationThreadPane.js";
30
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
31
- import Skeleton from "@rapidmx/react-shared/components/feedback/Skeleton.js";
32
- import { useUnlockPrompt } from "../shared/components/layout/UnlockPromptProvider.js";
33
-
34
- type ViewMode = "date" | "conversation";
35
-
36
- const MESSAGE_PAGE_SIZE = 50;
37
- const SEARCH_DEBOUNCE_MS = 300;
38
- const LIST_PREVIEW_MAX_LENGTH = 160;
39
-
40
- /** The literal outer-envelope `Subject` every encrypted message carries server-side - RFC 9788's
41
- * `hcp_baseline` policy obscures it to this exact string (see `smimeMessage.ts`'s
42
- * `applyBaselineOuterHeaders()`), which is also all `Message.subject` ever shows for one of these until
43
- * decrypted client-side. Used here to recognize which loaded rows are worth decrypting for display. */
44
- const ENCRYPTED_SUBJECT_PLACEHOLDER = "[...]";
45
-
46
- /** A row's client-recovered subject/preview, once decrypted - `undefined` fields mean nothing better
47
- * than the placeholder/blank server value was recoverable for that field specifically. */
48
- interface DecryptedRow {
49
- subject?: string;
50
- preview?: string;
51
- }
52
-
53
- /** Strips HTML down to plain text, for a short list-row preview of a decrypted body - mirrors
54
- * `searchTier3.ts`'s own private `stripHtml()` (not currently exported from `@rapidmx/react-shared`,
55
- * so duplicated here rather than pulled in through a package change just for this one small, pure
56
- * helper). Not a security boundary - the output only ever feeds plain text display, truncated below,
57
- * never rendered back into any DOM. */
58
- function stripHtmlToText(html: string): string {
59
- return html
60
- .replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, " ")
61
- .replace(/<[^>]+>/g, " ")
62
- .replace(/&nbsp;/gi, " ")
63
- .replace(/&amp;/gi, "&")
64
- .replace(/&lt;/gi, "<")
65
- .replace(/&gt;/gi, ">")
66
- .replace(/&quot;/gi, '"')
67
- .replace(/&#0*39;/gi, "'")
68
- .replace(/\s+/g, " ")
69
- .trim();
70
- }
71
-
72
- /**
73
- * Decrypts the subject/preview of every currently-loaded row whose subject is still the RFC 9788
74
- * placeholder (i.e. every encrypted message this device hasn't already resolved), keyed by uid - the
75
- * inbox-list counterpart to `MessageDetailPane`'s own single-message decrypt and `searchTier3.ts`'s
76
- * per-candidate decrypt. Bounded to `messages` (at most one loaded page, `MESSAGE_PAGE_SIZE`), never the
77
- * whole mailbox - matching `searchEncryptedCandidates()`'s own "bounded, not everything" scope. Runs only
78
- * once `unlocked` is available (the caller decides when to call this - see `InboxContent`'s own effect
79
- * and `handleUnlockList()`), and a single row's fetch/decrypt failure never blocks the rest.
80
- */
81
- async function decryptEncryptedRows(messages: Message[], unlocked: UnlockedKeys): Promise<Record<string, DecryptedRow>> {
82
- const encrypted = messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER);
83
- const entries = await Promise.all(
84
- encrypted.map(async (message): Promise<[string, DecryptedRow] | null> => {
85
- try {
86
- const rawMime = await getMessageRawContent(message.uid);
87
- const security = await evaluateMessageSecurity(rawMime, unlocked);
88
- if (!security.subject && !security.html) {
89
- return null;
90
- }
91
- const preview = security.html ? stripHtmlToText(security.html).slice(0, LIST_PREVIEW_MAX_LENGTH) : undefined;
92
- return [message.uid, { subject: security.subject, preview }];
93
- } catch {
94
- return null;
95
- }
96
- }),
97
- );
98
- const result: Record<string, DecryptedRow> = {};
99
- for (const entry of entries) {
100
- if (entry) {
101
- result[entry[0]] = entry[1];
102
- }
103
- }
104
- return result;
105
- }
106
-
107
- /** Merges Tier 1 (server, possibly `metadataOnly` for an encrypted message), Tier 2 (local index, fully
108
- * decrypted and re-scored), and Tier 3 (server-narrowed candidates, decrypted and re-scored) results into
109
- * one ranked list, per `specs/search.md` §7's "client MUST re-score all results it can see... normalise
110
- * into the same space rather than interleaving raw scores": each tier is normalized independently via
111
- * `normalizeServerScores()` before merging, since a Postgres/OpenSearch score, a local `bm25()` score,
112
- * and this module's own Tier 3 term-count score all occupy unrelated ranges. A uid present in more than
113
- * one list keeps only the last-inserted entry (Tier 2 wins over Tier 3 wins over Tier 1) - Tier 2 and
114
- * Tier 3 both represent genuine, content-verified scores for the same message, so which one "wins" on
115
- * overlap doesn't change correctness, only which of two equally-valid scores is shown; either supersedes
116
- * Tier 1's metadata-only guess for the same uid.
117
- *
118
- * Called progressively - once per tier as it resolves, each time with whatever tiers have reported so
119
- * far (an empty array for the rest) - by `InboxContent`'s own search orchestration below, per §_Progressive
120
- * Results_' "reordering is permitted and preferred over appending." This function itself stays pure and
121
- * stateless; it has no notion of "in progress" versus "final." */
122
- function mergeSearchResults(tier1: SearchResult[], tier2: SearchResult[], tier3: SearchResult[]): SearchResult[] {
123
- const normalizedTier1 = normalizeServerScores(tier1);
124
- const normalizedTier2 = normalizeServerScores(tier2);
125
- const normalizedTier3 = normalizeServerScores(tier3);
126
- const merged = new Map<string, { result: SearchResult; normalizedScore: number }>();
127
- for (const entry of normalizedTier1) {
128
- merged.set(entry.result.entityUid, entry);
129
- }
130
- for (const entry of normalizedTier3) {
131
- merged.set(entry.result.entityUid, entry);
132
- }
133
- for (const entry of normalizedTier2) {
134
- merged.set(entry.result.entityUid, entry);
135
- }
136
- return Array.from(merged.values())
137
- .sort((a, b) => b.normalizedScore - a.normalizedScore)
138
- .map((entry) => entry.result);
139
- }
140
-
141
- /** `type:` narrows `entityTypes`; when absent this still defaults to `["message"]` — a non-message hit
142
- * (contact/calendarEvent/note/task) has no `Message` to resolve via `getMessage()` below and is simply
143
- * dropped by the same eventually-consistent-index fallback that already existed, rather than rendered
144
- * (this inbox list only ever shows message rows; a real multi-entity-type results view is a separate,
145
- * larger UI project outside this pass). Shared by both the fresh-search orchestration and `loadMore()`
146
- * below, which each build this from the same `ParsedSearchQuery` differently only in `cursor`. */
147
- function tier1SearchParams(parsed: ParsedSearchQuery, cursor: string | undefined) {
148
- return {
149
- types: parsed.entityTypes ?? ["message"],
150
- cursor,
151
- limit: MESSAGE_PAGE_SIZE,
152
- from: parsed.from,
153
- to: parsed.to,
154
- cc: parsed.cc,
155
- subject: parsed.subject,
156
- hasAttachment: parsed.hasAttachment,
157
- before: parsed.before,
158
- after: parsed.after,
159
- folderUid: parsed.folderUid,
160
- flags: parsed.flags,
161
- labels: parsed.labels,
162
- };
163
- }
164
-
165
- /** How many of Tier 1's own `metadataOnly` hits (§7: "an encrypted entity matched only on server-visible
166
- * metadata... MUST be rendered as skeleton entries in place... using the metadata score as a provisional
167
- * position") are shown as skeleton rows at once - §_Progressive Results_' "Skeletons MUST be capped, at
168
- * approximately one and a half pages." A non-`metadataOnly` Tier 1 hit (a real, already-scored content
169
- * match — always the case for unencrypted mail) is never a skeleton and is never subject to this cap.
170
- *
171
- * A Tier 3 candidate that Tier 1 did *not* already surface has no provisional score/position of its own
172
- * under the spec's own wording above, so it is deliberately never pre-rendered as a skeleton here either
173
- * - it simply appears, fully resolved, once Tier 3 confirms it (see `InboxContent`'s search
174
- * orchestration). */
175
- const SKELETON_CAP = Math.round(MESSAGE_PAGE_SIZE * 1.5);
176
-
177
- function capSkeletons(tier1Hits: SearchResult[]): SearchResult[] {
178
- let skeletonsSeen = 0;
179
- return tier1Hits.filter((hit) => {
180
- if (!hit.metadataOnly) {
181
- return true;
182
- }
183
- skeletonsSeen += 1;
184
- return skeletonsSeen <= SKELETON_CAP;
185
- });
186
- }
187
-
188
- /** How many candidates Tier 3 pulls per distinct search - larger than one page's worth so several
189
- * `loadMore()` pages can be sliced from one decrypt pass (see `Tier3Cache` below) instead of a second,
190
- * separately expensive server round trip and re-decrypt for the same query. Bounded, not unlimited - per
191
- * this module's own `tier1SearchParams()` sibling, Tier 3's own candidate-narrowing is already the
192
- * "heaviest single client-side cost" tier (`specs/search.md` §9's identical framing for attachment
193
- * extraction); a query whose true candidate set exceeds this simply pages out once this cache is
194
- * exhausted; `loadMore()` reflects that honestly via `hasMore`. */
195
- const TIER3_CANDIDATE_LIMIT = 200;
196
-
197
- /** One entry per distinct (mailbox, query, "search all mail" toggle, unlocked-or-not) combination this
198
- * tab has already run Tier 3 for - keyed by `tier3CacheKey()` below. Tier 3's decrypt-and-match pass
199
- * (`searchEncryptedCandidates()`) is by far this search's most expensive step, so it runs once per
200
- * combination; every subsequent `loadMore()` page for that same combination slices further into the
201
- * same already-decrypted array. Session-scoped, per tab, with no explicit eviction - a small map that's
202
- * simply never read again once the query changes, the same shape `InboxContent`'s own `decryptedRows`
203
- * state already accepts for a similar "worth keeping around, not worth actively pruning" tradeoff.
204
- *
205
- * The unlocked-or-not dimension matters: re-running an identical query right after an on-demand unlock
206
- * (`handleUnlockSearch()`) MUST NOT reuse the "nothing to contribute" entry that same query cached while
207
- * still locked - `searchEncryptedCandidates()` degrades to `[]` for an absent `unlocked`, and that empty
208
- * result is exactly as cacheable/reusable as a real one, just under a different key. */
209
- type Tier3Cache = Map<string, SearchResult[]>;
210
-
211
- function tier3CacheKey(mailboxUid: string, fingerprint: string, searchAllMail: boolean, unlocked: boolean): string {
212
- return `${mailboxUid}|${fingerprint}|${String(searchAllMail)}|${String(unlocked)}`;
213
- }
214
-
215
- /** A search query's cache/cursor identity - stable across re-parsing the identical raw text, and
216
- * distinct for anything else (§8's "a query fingerprint, so a cursor cannot be replayed against a
217
- * different query"). `JSON.stringify` on `ParsedSearchQuery` is deterministic here because every one of
218
- * its own fields is a primitive or a `Date` (which serializes to a fixed ISO string) - no nested object
219
- * whose key order could vary between two structurally-identical parses of the same text. */
220
- function queryFingerprint(parsed: ParsedSearchQuery): string {
221
- return JSON.stringify(parsed);
222
- }
223
-
224
- /** Tier 3's own `before` bound, tightened to Tier 2's already-covered window (`coverage.indexedFrom`)
225
- * unless the reader explicitly asked to "Search all mail" - Tier 2 already holds fully-decrypted,
226
- * current content for everything at least that recent, so re-fetching and re-decrypting the same range
227
- * through Tier 3's slower candidate-narrowing path would be pure waste. Never *widens* an existing
228
- * `before:` the query already specified. */
229
- function tightenBeforeToCoverage(parsed: ParsedSearchQuery, coverage: Coverage | undefined, searchAllMail: boolean): ParsedSearchQuery {
230
- if (searchAllMail || !coverage?.indexedFrom) {
231
- return parsed;
232
- }
233
- const coverageBound = new Date(coverage.indexedFrom);
234
- const effectiveBefore = parsed.before && parsed.before.getTime() < coverageBound.getTime() ? parsed.before : coverageBound;
235
- return { ...parsed, before: effectiveBefore };
236
- }
237
-
238
- /** The paging state for one search, composited across all three tiers (`specs/search.md` §8) - opaque to
239
- * every caller the same way `SearchResultPage.nextCursor` is opaque to callers of `search()` itself.
240
- * Tier 1 keeps the server's own opaque cursor unmodified; Tier 2 (the local index) and Tier 3 (the
241
- * cached, already-decrypted candidate array - see `Tier3Cache` above) are both this client's own state,
242
- * so their "position" is just a plain offset into each. */
243
- interface CompositeCursor {
244
- tier1Cursor?: string;
245
- tier2Offset: number;
246
- tier3Offset: number;
247
- fingerprint: string;
248
- }
249
-
250
- /** `undefined` for a missing, corrupted, or foreign-query cursor - every caller already treats "no
251
- * cursor" as "start this tier from the beginning," so there's no separate error path needed here. */
252
- function decodeCursor(raw: CompositeCursor | undefined, fingerprint: string): CompositeCursor | undefined {
253
- return raw?.fingerprint === fingerprint ? raw : undefined;
254
- }
255
-
256
- /** Resolves every hit's `entityUid` to a full `Message` via `getMessage()`, reusing `cache` across
257
- * repeated calls within the same search pass - `InboxContent`'s own search orchestration below re-merges
258
- * and re-resolves the *entire* current hit set each time a tier resolves, so without this cache every
259
- * stage would re-fetch messages an earlier stage already fetched. A hit whose message no longer resolves
260
- * (deleted after being indexed, or the fetch itself failed) is cached as `null` and dropped - the same
261
- * "a search hit can briefly outlive the message it points to" tolerance this function's inline
262
- * predecessor already had. */
263
- async function resolveHitsToMessages(
264
- hits: SearchResult[],
265
- cache: Map<string, Message | null>,
266
- ): Promise<{ messages: Message[]; snippets: Record<string, string> }> {
267
- const toFetch = hits.filter((hit) => !cache.has(hit.entityUid));
268
- await Promise.all(
269
- toFetch.map(async (hit) => {
270
- const message = await getMessage(hit.entityUid).catch(() => null);
271
- cache.set(hit.entityUid, message);
272
- }),
273
- );
274
- const messages: Message[] = [];
275
- const snippets: Record<string, string> = {};
276
- for (const hit of hits) {
277
- const message = cache.get(hit.entityUid);
278
- if (!message) {
279
- continue;
280
- }
281
- messages.push(message);
282
- if (hit.snippet) {
283
- snippets[message.uid] = hit.snippet;
284
- }
285
- }
286
- return { messages, snippets };
287
- }
288
-
289
- /** Flattens and sorts a per-mailbox fetch into one merged, newest-first list - the aggregate ("All
290
- * Inboxes" etc.) equivalent of `mergeSearchResults()` above, but simpler: an aggregated message has no
291
- * natural relevance score to normalize, so this only ever sorts by `receivedDate`. */
292
- function mergeInboxMessages(perMailbox: { mailbox: Mailbox; messages: Message[] }[]): Message[] {
293
- return perMailbox
294
- .flatMap((entry) => entry.messages)
295
- .sort((a, b) => new Date(b.receivedDate).getTime() - new Date(a.receivedDate).getTime());
296
- }
297
-
298
- /**
299
- * Fans out one `listMessages()` call per accessible mailbox that has a folder of `type`, merges the
300
- * results newest-first. A mailbox with no matching folder, or whose fetch fails, simply contributes
301
- * nothing - one mailbox's absence/failure must not blank out every other mailbox's messages.
302
- *
303
- * **Pagination scope trim (deliberate, matching this file's own documented Tier 2/3 tradeoffs)**: there is
304
- * no composite cursor across an arbitrary number of independently-paginated mailboxes in this pass - this
305
- * always fetches exactly each mailbox's own first page (`MESSAGE_PAGE_SIZE`) and the caller never offers a
306
- * "load more" for the result (see `InboxContent`'s own `hasMore` handling in aggregate mode) - a real
307
- * composite-cursor "load more" per mailbox is a natural v2 if usage shows people scrolling past the first
308
- * page in aggregate view often.
309
- */
310
- async function fetchAggregateMessages(mailboxFolders: MailboxFolders[], type: AggregateFolderType): Promise<Message[]> {
311
- const perMailbox = await Promise.all(
312
- mailboxFolders.map(async ({ mailbox, folders }) => {
313
- const folder = folders.find((f) => f.type === type);
314
- if (!folder) {
315
- return { mailbox, messages: [] as Message[] };
316
- }
317
- const messages = await listMessages(folder.uid, { limit: MESSAGE_PAGE_SIZE }).catch(() => [] as Message[]);
318
- return { mailbox, messages };
319
- }),
320
- );
321
- return mergeInboxMessages(perMailbox);
322
- }
323
-
324
- export default function InboxPage(props: MailShellProps) {
325
- return (
326
- <MailShell {...props}>
327
- <InboxContent />
328
- </MailShell>
329
- );
330
- }
331
-
332
- function InboxContent() {
333
- const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType } = useMailShell();
334
- const isMobile = useIsMobile();
335
- const { requestUnlock } = useUnlockPrompt();
336
- const [viewMode, setViewMode] = useState<ViewMode>("date");
337
- const [messages, setMessages] = useState<Message[]>([]);
338
- const [conversations, setConversations] = useState<ConversationSummary[]>([]);
339
- const [loading, setLoading] = useState(true);
340
- const [loadingMore, setLoadingMore] = useState(false);
341
- const [hasMore, setHasMore] = useState(false);
342
- const [error, setError] = useState<string | null>(null);
343
- const [selectedUid, setSelectedUid] = useState<string | null>(null);
344
- const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null);
345
- const [classificationFilter, setClassificationFilter] = useState<MessageClassification | "all">("all");
346
- const [searchInput, setSearchInput] = useState("");
347
- const [searchQuery, setSearchQuery] = useState("");
348
- const [snippets, setSnippets] = useState<Record<string, string>>({});
349
- const [labels, setLabels] = useState<Label[]>([]);
350
- // Tier 2's own reported window coverage for the current search - undefined outside a search, or
351
- // before Tier 2 has resolved yet for this search pass.
352
- const [coverage, setCoverage] = useState<Coverage | undefined>(undefined);
353
- // Keyed by message uid - see decryptEncryptedRows(). Never cleared on folder/search switches (a
354
- // decrypted row stays decrypted for the rest of the session; re-decrypting on every navigation would
355
- // waste work for no benefit), only ever added to.
356
- const [decryptedRows, setDecryptedRows] = useState<Record<string, DecryptedRow>>({});
357
- // Bumped after a successful on-demand unlock to re-run the search effect below - it's not a
358
- // dependency the effect could otherwise react to (getUnlockedKeys() is a plain module-level read, not
359
- // React state; see keySession.ts's own doc comment).
360
- const [unlockRefresh, setUnlockRefresh] = useState(0);
361
- // §_Progressive Results_: which of the current search's uids are still an unconfirmed Tier 1
362
- // `metadataOnly` guess (rendered as a skeleton row - see the JSX below) - empty outside a search, and
363
- // always empty again once every tier has reported for the current pass (each unresolved entry is by
364
- // then either confirmed, real content, or pruned - see the search orchestration effect).
365
- const [pendingUids, setPendingUids] = useState<Set<string>>(new Set());
366
- // Withholds a hard result count until every tier has reported for the current search pass (§_Progressive
367
- // Results_: "Never show a hard count until every tier has reported... A settled count is the signal
368
- // that ordering is final"). Reset on every fresh search; irrelevant outside search mode.
369
- const [tier1Done, setTier1Done] = useState(false);
370
- const [tier2Done, setTier2Done] = useState(false);
371
- const [tier3Done, setTier3Done] = useState(false);
372
- // Toggled by the "Search all mail" action next to the results count - removes Tier 3's own default
373
- // bound (tightened to Tier 2's coverage window otherwise - see tightenBeforeToCoverage()) for one
374
- // re-run. Reset to false whenever the query itself changes (see the search effect's own dependency).
375
- const [searchAllMail, setSearchAllMail] = useState(false);
376
- // Search stays real-folder-only - Tier 1/2/3 are all deeply mailbox/folder-scoped, and extending them
377
- // to span an arbitrary number of mailboxes is out of scope for this pass (see the aggregate-fetch
378
- // branch below, which the search effect never reaches while `folderUid` is unset).
379
- const isSearching = viewMode === "date" && searchQuery.length > 0 && !aggregateFolderType;
380
- const pageRef = useRef(0);
381
- const scrollContainerRef = useRef<HTMLDivElement | null>(null);
382
- const sentinelRef = useRef<HTMLDivElement | null>(null);
383
- // The mailbox unlock/decrypt call sites below treat as "the" mailbox when there's no single selected
384
- // one (aggregate mode) - mirrors `MailShell`'s own identical `defaultMailboxUid` fallback. An
385
- // aggregate-view row from a *different*, not-yet-unlocked mailbox stays locked until that mailbox's
386
- // own folder view is opened directly - an accepted limitation, not a bug (see `MailShell`'s own doc
387
- // comment on the same tradeoff for its `LocalIndexLifecycle`/`KeyEnrollmentGate` wiring).
388
- const activeMailboxUid = mailboxUid ?? mailboxes.find((mb) => mb.ownerUserUid)?.uid ?? mailboxes[0]?.uid;
389
- const mailboxKeys = mailboxes.find((mb) => mb.uid === activeMailboxUid)?.keys ?? [];
390
- // Reset to a fresh Map at the start of every new search pass (see the search effect below) - see
391
- // resolveHitsToMessages()'s own doc comment on why this needs to persist *within* one pass but not
392
- // across passes (a stale `null` for a uid that's since become resolvable elsewhere must not stick).
393
- const resolvedMessageCacheRef = useRef<Map<string, Message | null>>(new Map());
394
- // Session-scoped, never explicitly cleared - see Tier3Cache's own doc comment above.
395
- const tier3CacheRef = useRef<Tier3Cache>(new Map());
396
- // The latest composite cursor this search pass has reached - read by loadMore(), written at the end
397
- // of both the fresh-search orchestration and loadMore() itself. Not React state: it never drives a
398
- // render on its own, only what loadMore() does with it later.
399
- const compositeCursorRef = useRef<CompositeCursor | undefined>(undefined);
400
- // Guards every async stage of the search orchestration below against a stale, still-in-flight pass
401
- // clobbering state for a newer one that started after it (the query changed again, or the folder
402
- // did) - the same `loadSeq`-style monotonic-id pattern already used elsewhere in this codebase (e.g.
403
- // `settings/privacy/index.tsx`'s `ExportSection`), generalized here across three independently-timed
404
- // async stages instead of one.
405
- const searchRunIdRef = useRef(0);
406
- // `messages` themselves aren't a dependency here on purpose - a message uid, once decrypted, is
407
- // never re-decrypted just because the list re-renders with the same rows (e.g. a folder-unrelated
408
- // state update elsewhere). New rows (a fresh page load, load-more, or a completed search) each
409
- // re-trigger this the normal way, by changing `messages` itself.
410
- //
411
- // Scoped to `activeMailboxUid`'s own rows only - in aggregate mode `messages` can span several
412
- // mailboxes, but only one mailbox's keys are ever being unlocked/tracked here (see `activeMailboxUid`'s
413
- // own doc comment above); an encrypted row from any other mailbox simply isn't a candidate for this
414
- // auto-decrypt or the manual unlock banner below.
415
- const undecryptedEncryptedUids = messages
416
- .filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER && !decryptedRows[m.uid] && m.mailboxUid === activeMailboxUid)
417
- .map((m) => m.uid);
418
-
419
- // Once unlocked, silently decrypt this page's own encrypted rows to show their real subject/preview -
420
- // no prompt needed here, the same way searchEncryptedCandidates() already auto-includes decrypted
421
- // matches once unlocked without asking again. Only the *first* unlock (or a fresh page of messages
422
- // arriving) needs this; `handleUnlockList()` below covers the not-yet-unlocked case explicitly.
423
- useEffect(() => {
424
- if (undecryptedEncryptedUids.length === 0 || !activeMailboxUid) {
425
- return;
426
- }
427
- const unlocked = getUnlockedKeys(activeMailboxUid);
428
- if (!unlocked) {
429
- return;
430
- }
431
- let cancelled = false;
432
- decryptEncryptedRows(
433
- messages.filter((m) => undecryptedEncryptedUids.includes(m.uid)),
434
- unlocked,
435
- ).then((decrypted) => {
436
- if (!cancelled && Object.keys(decrypted).length > 0) {
437
- setDecryptedRows((prev) => ({ ...prev, ...decrypted }));
438
- }
439
- });
440
- return () => {
441
- cancelled = true;
442
- };
443
- // eslint-disable-next-line react-hooks/exhaustive-deps
444
- }, [messages, unlockRefresh]);
445
-
446
- async function handleUnlockList() {
447
- try {
448
- const unlocked = await requestUnlock(activeMailboxUid!, mailboxKeys);
449
- const decrypted = await decryptEncryptedRows(
450
- messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER && m.mailboxUid === activeMailboxUid),
451
- unlocked,
452
- );
453
- setDecryptedRows((prev) => ({ ...prev, ...decrypted }));
454
- } catch {
455
- // User dismissed the unlock dialog - rows stay exactly as they were.
456
- }
457
- }
458
-
459
- async function handleUnlockSearch() {
460
- try {
461
- await requestUnlock(activeMailboxUid!, mailboxKeys);
462
- setUnlockRefresh((n) => n + 1);
463
- } catch {
464
- // User dismissed the unlock dialog - the search results stay exactly as they were.
465
- }
466
- }
467
-
468
- // Labels are mailbox-wide, not folder-scoped - fetched once per mailbox rather than per message, and
469
- // handed to every `MessageDetailPane` instance below. A failure here just means the Labels control
470
- // stays hidden (an empty `labels` array) rather than blocking the rest of the inbox - it's a small
471
- // enhancement, not critical path the way the message list itself is. Keyed on `activeMailboxUid`, which
472
- // `MailShell` guarantees resolves (it never renders this component without at least one mailbox) even
473
- // in aggregate mode, where there's no single selected `mailboxUid`.
474
- useEffect(() => {
475
- if (!activeMailboxUid) {
476
- return;
477
- }
478
- listLabels(activeMailboxUid, { limit: 200 })
479
- .then(setLabels)
480
- .catch(() => setLabels([]));
481
- }, [activeMailboxUid]);
482
-
483
- // Debounce the raw input into the query actually searched, so every keystroke doesn't fire a request.
484
- useEffect(() => {
485
- const handle = setTimeout(() => setSearchQuery(searchInput.trim()), SEARCH_DEBOUNCE_MS);
486
- return () => clearTimeout(handle);
487
- }, [searchInput]);
488
-
489
- // Conversations are computed mailbox-wide (see `conversationsApi.ts`), not scoped to the selected
490
- // folder — switching into "By conversation" mode replaces the per-folder list entirely, and the
491
- // folder-tree sidebar's own selection becomes purely informational until switching back to "By date".
492
- useEffect(() => {
493
- setSelectedUid(null);
494
- setSelectedConversationId(null);
495
- setClassificationFilter("all");
496
- pageRef.current = 0;
497
- compositeCursorRef.current = undefined;
498
- setHasMore(false);
499
-
500
- if (viewMode === "conversation") {
501
- // Conversations stay single-mailbox (not aggregated across mailboxes in this pass) - in
502
- // aggregate mode this falls back to `activeMailboxUid`, the same mailbox unlock/labels use.
503
- if (!activeMailboxUid) {
504
- return;
505
- }
506
- setLoading(true);
507
- setError(null);
508
- listConversations(activeMailboxUid)
509
- .then(setConversations)
510
- .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load conversations."))
511
- .finally(() => setLoading(false));
512
- return;
513
- }
514
-
515
- if (aggregateFolderType) {
516
- setLoading(true);
517
- setError(null);
518
- let cancelled = false;
519
- // hasMore stays false (set above) - see fetchAggregateMessages()'s own pagination scope trim.
520
- fetchAggregateMessages(mailboxFolders, aggregateFolderType)
521
- .then((results) => {
522
- if (!cancelled) {
523
- setMessages(results);
524
- }
525
- })
526
- .finally(() => {
527
- if (!cancelled) {
528
- setLoading(false);
529
- }
530
- });
531
- return () => {
532
- cancelled = true;
533
- };
534
- }
535
-
536
- if (!folderUid) {
537
- setMessages([]);
538
- setLoading(false);
539
- return;
540
- }
541
- setLoading(true);
542
- setError(null);
543
-
544
- if (isSearching) {
545
- setCoverage(undefined);
546
- setPendingUids(new Set());
547
- setTier1Done(false);
548
- setTier2Done(false);
549
- setTier3Done(false);
550
- resolvedMessageCacheRef.current = new Map();
551
- searchRunIdRef.current += 1;
552
- const myRunId = searchRunIdRef.current;
553
- const parsed = parseSearchQuery(searchQuery);
554
- const unlocked = getUnlockedKeys(mailboxUid!);
555
- const fingerprint = queryFingerprint(parsed);
556
-
557
- // Recomputes and re-renders the merged list from whatever tiers have reported so far -
558
- // called once after Tier 1+2 (interim: unconfirmed Tier 1 metadataOnly hits render as
559
- // skeletons) and again after Tier 3 (final: anything still unconfirmed is pruned instead -
560
- // §_Progressive Results_' "Skeletons resolve or disappear"). Bails out via `myRunId` if a
561
- // newer search pass has since started.
562
- async function reveal(tier1Hits: SearchResult[], tier2Hits: SearchResult[], tier3Hits: SearchResult[], final: boolean) {
563
- const capped = capSkeletons(tier1Hits);
564
- const confirmed = new Set([...tier2Hits, ...tier3Hits].map((r) => r.entityUid));
565
- const effectiveTier1 = final ? capped.filter((hit) => !hit.metadataOnly || confirmed.has(hit.entityUid)) : capped;
566
- const merged = mergeSearchResults(effectiveTier1, tier2Hits, tier3Hits);
567
- const { messages: resolved, snippets: resolvedSnippets } = await resolveHitsToMessages(
568
- merged,
569
- resolvedMessageCacheRef.current,
570
- );
571
- if (searchRunIdRef.current !== myRunId) {
572
- return;
573
- }
574
- setMessages(resolved);
575
- setSnippets(resolvedSnippets);
576
- if (final) {
577
- setPendingUids(new Set());
578
- } else {
579
- const pending = new Set<string>();
580
- for (const hit of capped) {
581
- if (hit.metadataOnly && !confirmed.has(hit.entityUid)) {
582
- pending.add(hit.entityUid);
583
- }
584
- }
585
- setPendingUids(pending);
586
- }
587
- }
588
-
589
- void (async () => {
590
- try {
591
- const [tier1Page, tier2Page] = await Promise.all([
592
- searchMailbox(parsed.text, tier1SearchParams(parsed, undefined)),
593
- searchLocalIndex(mailboxUid!, parsed, unlocked, MESSAGE_PAGE_SIZE, 0),
594
- ]);
595
- if (searchRunIdRef.current !== myRunId) {
596
- return;
597
- }
598
- setTier1Done(true);
599
- setTier2Done(true);
600
- setCoverage(tier2Page.coverage);
601
- setLoading(false);
602
- await reveal(tier1Page.results, tier2Page.results, [], false);
603
-
604
- const tightened = tightenBeforeToCoverage(parsed, tier2Page.coverage, searchAllMail);
605
- const cacheKey = tier3CacheKey(mailboxUid!, fingerprint, searchAllMail, !!unlocked);
606
- let tier3Full = tier3CacheRef.current.get(cacheKey);
607
- if (!tier3Full) {
608
- tier3Full = await searchEncryptedCandidates(tightened, unlocked, TIER3_CANDIDATE_LIMIT);
609
- if (searchRunIdRef.current !== myRunId) {
610
- return;
611
- }
612
- tier3CacheRef.current.set(cacheKey, tier3Full);
613
- }
614
- const tier3Page = tier3Full.slice(0, MESSAGE_PAGE_SIZE);
615
- setTier3Done(true);
616
- compositeCursorRef.current = {
617
- tier1Cursor: tier1Page.nextCursor,
618
- tier2Offset: tier2Page.results.length,
619
- tier3Offset: tier3Page.length,
620
- fingerprint,
621
- };
622
- setHasMore(!!tier1Page.nextCursor || tier2Page.hasMore || tier3Page.length < tier3Full.length);
623
- await reveal(tier1Page.results, tier2Page.results, tier3Page, true);
624
- } catch (err) {
625
- if (searchRunIdRef.current === myRunId) {
626
- setError(err instanceof ApiRequestError ? err.message : "Search failed.");
627
- setLoading(false);
628
- }
629
- }
630
- })();
631
- return;
632
- }
633
-
634
- listMessages(folderUid, { limit: MESSAGE_PAGE_SIZE })
635
- .then((results) => {
636
- setMessages(results);
637
- setHasMore(results.length === MESSAGE_PAGE_SIZE);
638
- })
639
- .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load messages."))
640
- .finally(() => setLoading(false));
641
- // `unlockRefresh`/`searchAllMail` are dependencies solely so `handleUnlockSearch()`/"Search all
642
- // mail" can force this effect to re-run the search above - Tier 3 (encrypted) results silently
643
- // contribute nothing without unlocked keys, so this is what actually makes them appear once the
644
- // user unlocks, and what makes "Search all mail" actually remove Tier 3's coverage bound. Neither
645
- // has any effect on the non-search branch below; re-running it with identical inputs just
646
- // re-fetches the same page.
647
- }, [viewMode, folderUid, mailboxUid, isSearching, searchQuery, unlockRefresh, searchAllMail, aggregateFolderType, mailboxFolders, activeMailboxUid]);
648
-
649
- function handleSearchAllMail() {
650
- setSearchAllMail(true);
651
- }
652
-
653
- const loadMore = useCallback(async () => {
654
- if (loadingMore || !hasMore || loading || viewMode !== "date" || !folderUid) {
655
- return;
656
- }
657
- setLoadingMore(true);
658
- try {
659
- if (isSearching) {
660
- const parsed = parseSearchQuery(searchQuery);
661
- const unlocked = getUnlockedKeys(mailboxUid!);
662
- const fingerprint = queryFingerprint(parsed);
663
- const cursor = decodeCursor(compositeCursorRef.current, fingerprint);
664
-
665
- const [tier1Page, tier2Page] = await Promise.all([
666
- searchMailbox(parsed.text, tier1SearchParams(parsed, cursor?.tier1Cursor)),
667
- searchLocalIndex(mailboxUid!, parsed, unlocked, MESSAGE_PAGE_SIZE, cursor?.tier2Offset ?? 0),
668
- ]);
669
- const cacheKey = tier3CacheKey(mailboxUid!, fingerprint, searchAllMail, !!unlocked);
670
- const tier3Full = tier3CacheRef.current.get(cacheKey) ?? [];
671
- const tier3Offset = cursor?.tier3Offset ?? 0;
672
- const tier3Page = tier3Full.slice(tier3Offset, tier3Offset + MESSAGE_PAGE_SIZE);
673
-
674
- // Unlike the fresh-search pass above, a load-more page is resolved and appended in one
675
- // shot rather than progressively revealed - rows already on screen shouldn't reorder or
676
- // grow skeletons out from under a reader who has since scrolled past them. Any Tier 1
677
- // metadataOnly hit this page that neither Tier 2 nor Tier 3 (both already awaited above)
678
- // confirms simply keeps showing its existing placeholder text rather than a live skeleton
679
- // - a deliberate, documented scope trim of progressive reveal to the first page only.
680
- const merged = mergeSearchResults(capSkeletons(tier1Page.results), tier2Page.results, tier3Page);
681
- const { messages: more, snippets: moreSnippets } = await resolveHitsToMessages(merged, resolvedMessageCacheRef.current);
682
- setMessages((prev) => [...prev, ...more]);
683
- setSnippets((prev) => ({ ...prev, ...moreSnippets }));
684
-
685
- const nextTier3Offset = tier3Offset + tier3Page.length;
686
- compositeCursorRef.current = {
687
- tier1Cursor: tier1Page.nextCursor,
688
- tier2Offset: (cursor?.tier2Offset ?? 0) + tier2Page.results.length,
689
- tier3Offset: nextTier3Offset,
690
- fingerprint,
691
- };
692
- setHasMore(!!tier1Page.nextCursor || tier2Page.hasMore || nextTier3Offset < tier3Full.length);
693
- } else {
694
- const nextPage = pageRef.current + 1;
695
- const more = await listMessages(folderUid, { page: nextPage, limit: MESSAGE_PAGE_SIZE });
696
- setMessages((prev) => [...prev, ...more]);
697
- setHasMore(more.length === MESSAGE_PAGE_SIZE);
698
- pageRef.current = nextPage;
699
- }
700
- } catch (err) {
701
- setError(err instanceof ApiRequestError ? err.message : "Could not load more messages.");
702
- } finally {
703
- setLoadingMore(false);
704
- }
705
- }, [loadingMore, hasMore, loading, viewMode, folderUid, isSearching, searchQuery, searchAllMail, mailboxUid]);
706
-
707
- // Always calls the latest `loadMore` closure so the effect below doesn't need `loadMore` itself in its
708
- // dependency array (it changes on every keystroke/page load, which would otherwise mean nothing here).
709
- const loadMoreRef = useRef(loadMore);
710
- loadMoreRef.current = loadMore;
711
-
712
- // `hasMore` is deliberately a dependency: the sentinel div only renders while `hasMore` is true (see
713
- // the JSX below), so this effect must re-run when it flips - otherwise a run that fires before the
714
- // first page of results has loaded (sentinel not in the DOM yet, `sentinelRef.current` still null)
715
- // would bail out once and never attach an observer to the sentinel that appears moments later.
716
- useEffect(() => {
717
- const sentinel = sentinelRef.current;
718
- if (!sentinel || viewMode !== "date") {
719
- return;
720
- }
721
- const observer = new IntersectionObserver(
722
- (entries) => {
723
- if (entries[0]?.isIntersecting) {
724
- void loadMoreRef.current();
725
- }
726
- },
727
- { root: scrollContainerRef.current, rootMargin: "200px" },
728
- );
729
- observer.observe(sentinel);
730
- return () => observer.disconnect();
731
- }, [viewMode, folderUid, isSearching, searchQuery, hasMore]);
732
-
733
- const selected = messages.find((m) => m.uid === selectedUid) ?? null;
734
- const selectedConversation = conversations.find((c) => c.conversationId === selectedConversationId) ?? null;
735
- const attachments = useMessageAttachments(selected);
736
- useMarkMessageRead(selected, (updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m))));
737
- // Search results can span every folder in the mailbox, not just the one selected in the sidebar - a
738
- // selected message's own folderUid is the only reliable source for its actual folder type once
739
- // searching (outside search, every message in `messages` already comes from `folderUid` itself, so
740
- // this falls back to the sidebar selection unchanged). Aggregate views span every *mailbox* too, so
741
- // the folder list consulted is the selected message's own mailbox's, not the shell's ambient one.
742
- const spansFolders = isSearching || !!aggregateFolderType;
743
- const selectedFolderUid = spansFolders ? (selected?.folderUid ?? folderUid) : folderUid;
744
- const selectedMailboxUid = spansFolders ? (selected?.mailboxUid ?? activeMailboxUid) : mailboxUid;
745
- const folders = mailboxFolders.find((mf) => mf.mailbox.uid === selectedMailboxUid)?.folders ?? [];
746
- const isSentItems = folders.find((f) => f.uid === selectedFolderUid)?.type === "sent_items";
747
- const isOutbox = folders.find((f) => f.uid === selectedFolderUid)?.type === "outbox";
748
- const isInbox = folders.find((f) => f.uid === selectedFolderUid)?.type === "inbox";
749
- const draftsFolderUid = folders.find((f) => f.type === "drafts")?.uid;
750
-
751
- // Focused/Other is an Inbox-only concept (see `MessageDetailPane`'s own `isInbox` doc comment) — the
752
- // sub-tabs only ever render there, so a message with no `inferenceClassification` (the common case:
753
- // absent means Focused) or an explicit `"focused"` counts as Focused, everything else as Other. Not
754
- // offered for an aggregate view (each mailbox classifies independently; merging that is out of scope).
755
- const visibleMessages =
756
- !isSearching && !aggregateFolderType && isInbox && classificationFilter !== "all"
757
- ? messages.filter((m) =>
758
- classificationFilter === "other"
759
- ? m.inferenceClassification === "other"
760
- : m.inferenceClassification !== "other",
761
- )
762
- : messages;
763
-
764
- function handleSelect(message: Message) {
765
- if (isMobile) {
766
- window.location.href = `/messages/${encodeURIComponent(message.uid)}`;
767
- return;
768
- }
769
- setSelectedUid(message.uid);
770
- }
771
-
772
- function handleSelectConversation(conversation: ConversationSummary) {
773
- if (isMobile) {
774
- // No dedicated mobile thread route yet — the existing single-message detail route already
775
- // handles any message uid regardless of conversation grouping, so land on the most recent
776
- // message in the thread rather than building a second mobile detail page for this phase.
777
- // `messageUids` always has at least one entry — a `ConversationSummary` only ever exists
778
- // because it was grouped from real messages (see `BaseMessageRoute.conversations()`).
779
- const latestUid = conversation.messageUids[conversation.messageUids.length - 1];
780
- window.location.href = `/messages/${encodeURIComponent(latestUid)}`;
781
- return;
782
- }
783
- setSelectedConversationId(conversation.conversationId);
784
- }
785
-
786
- if (!folderUid && !aggregateFolderType) {
787
- // `MailShell` never renders this component at all until a mailbox is resolved (see its own
788
- // full-screen `MailboxProvisioning` takeover otherwise) — this is purely the brief gap before
789
- // that mailbox's own folder list has finished loading, not a "no mailbox" state. Distinct text
790
- // from the message list's own "Loading…" below — otherwise the two transient states become
791
- // indistinguishable to anything (a test, a user re-reading the screen) that catches this one.
792
- return <p className="p-8 text-sm text-text-muted">Loading your mailbox&hellip;</p>;
793
- }
794
-
795
- return (
796
- <div className="flex h-full min-h-0">
797
- <div ref={scrollContainerRef} className="w-full md:w-96 shrink-0 md:border-r border-border overflow-y-auto">
798
- <div className="flex border-b border-border text-sm">
799
- <button
800
- type="button"
801
- onClick={() => setViewMode("date")}
802
- className={[
803
- "flex-1 py-2 font-semibold",
804
- viewMode === "date" ? "text-primary-dark border-b-2 border-primary-dark" : "text-text-muted",
805
- ].join(" ")}
806
- >
807
- By date
808
- </button>
809
- <button
810
- type="button"
811
- onClick={() => setViewMode("conversation")}
812
- className={[
813
- "flex-1 py-2 font-semibold",
814
- viewMode === "conversation" ? "text-primary-dark border-b-2 border-primary-dark" : "text-text-muted",
815
- ].join(" ")}
816
- >
817
- By conversation
818
- </button>
819
- </div>
820
- {viewMode === "conversation" && (
821
- <p className="p-3 text-xs text-text-muted border-b border-border">
822
- Showing every conversation in this mailbox — the selected folder doesn&apos;t filter this view.
823
- </p>
824
- )}
825
- {viewMode === "date" && (
826
- <div className="p-2 border-b border-border">
827
- <input
828
- type="search"
829
- value={searchInput}
830
- onChange={(e) => setSearchInput(e.target.value)}
831
- placeholder={aggregateFolderType ? "Open a mailbox's own folder to search" : "Search all mail…"}
832
- aria-label="Search all mail"
833
- disabled={!!aggregateFolderType}
834
- className="w-full text-sm px-3 py-1.5 rounded-md border border-border bg-surface disabled:opacity-55"
835
- />
836
- </div>
837
- )}
838
- {viewMode === "date" && isInbox && !isSearching && !aggregateFolderType && (
839
- <div className="flex border-b border-border text-xs">
840
- {(["all", "focused", "other"] as const).map((value) => (
841
- <button
842
- key={value}
843
- type="button"
844
- onClick={() => setClassificationFilter(value)}
845
- className={[
846
- "flex-1 py-1.5 font-semibold",
847
- classificationFilter === value
848
- ? "text-primary-dark border-b-2 border-primary-dark"
849
- : "text-text-muted",
850
- ].join(" ")}
851
- >
852
- {value === "all" ? "All" : value === "focused" ? "Focused" : "Other"}
853
- </button>
854
- ))}
855
- </div>
856
- )}
857
-
858
- {isSearching && (
859
- <div className="px-4 py-1.5 text-xs text-text-muted border-b border-border flex items-center justify-between gap-2">
860
- {/* §_Progressive Results_: "Never show a hard count until every tier has reported.
861
- Display n of ??, or omit the count. A settled count is the signal that ordering
862
- is final." */}
863
- <span>
864
- {tier1Done && tier2Done && tier3Done
865
- ? `${visibleMessages.length} result${visibleMessages.length === 1 ? "" : "s"}`
866
- : `${visibleMessages.length} of ??`}
867
- </span>
868
- {tier2Done && !searchAllMail && (
869
- <button
870
- type="button"
871
- onClick={handleSearchAllMail}
872
- className="text-primary-dark hover:underline font-medium shrink-0"
873
- >
874
- Search all mail
875
- </button>
876
- )}
877
- </div>
878
- )}
879
- {isSearching && coverage?.indexedFrom && (
880
- <div className="px-4 py-1.5 text-xs text-text-muted border-b border-border">
881
- Local search covers messages back to {new Date(coverage.indexedFrom).toLocaleDateString()}
882
- {coverage.building ? " (still building)" : ""}
883
- {searchAllMail
884
- ? " - searching everything, not just recent mail."
885
- : " - older encrypted mail is still searched, just slower."}
886
- </div>
887
- )}
888
- {isSearching && !getUnlockedKeys(mailboxUid!) && (
889
- <div className="px-4 py-2 border-b border-border bg-surface-alt">
890
- <button
891
- type="button"
892
- onClick={handleUnlockSearch}
893
- className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
894
- >
895
- <HiOutlineLockClosed size={12} aria-hidden="true" />
896
- Unlock to include encrypted messages in these results
897
- </button>
898
- </div>
899
- )}
900
- {!isSearching && viewMode === "date" && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid!) && (
901
- <div className="px-4 py-2 border-b border-border bg-surface-alt">
902
- <button
903
- type="button"
904
- onClick={handleUnlockList}
905
- className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
906
- >
907
- <HiOutlineLockClosed size={12} aria-hidden="true" />
908
- Unlock to show {undecryptedEncryptedUids.length === 1 ? "an encrypted message's" : "encrypted messages'"} subject
909
- </button>
910
- </div>
911
- )}
912
-
913
- {error && (
914
- <div className="p-4">
915
- <Alert>{error}</Alert>
916
- </div>
917
- )}
918
-
919
- {loading ? (
920
- <p className="p-4 text-sm text-text-muted">Loading&hellip;</p>
921
- ) : viewMode === "conversation" ? (
922
- <ConversationList
923
- conversations={conversations}
924
- selectedId={selectedConversationId}
925
- onSelect={handleSelectConversation}
926
- />
927
- ) : visibleMessages.length === 0 ? (
928
- <p className="p-4 text-sm text-text-muted">
929
- {isSearching
930
- ? `No messages match "${searchQuery}".`
931
- : classificationFilter === "all"
932
- ? "No messages in this folder."
933
- : "No messages here."}
934
- </p>
935
- ) : (
936
- <>
937
- <ul>
938
- {visibleMessages.map((message) => (
939
- <li key={message.uid}>
940
- <button
941
- type="button"
942
- onClick={() => handleSelect(message)}
943
- className={[
944
- "w-full text-left px-4 py-3 border-b border-border",
945
- message.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt",
946
- message.flags.read ? "" : "font-semibold",
947
- ].join(" ")}
948
- >
949
- <div className="flex items-center justify-between gap-2 text-sm">
950
- <span className="truncate">{message.from.displayName || message.from.address}</span>
951
- <span className="text-xs text-text-muted shrink-0">
952
- {new Date(message.receivedDate).toLocaleDateString()}
953
- </span>
954
- </div>
955
- {aggregateFolderType && (
956
- // The one view where a row needs to say which mailbox it came from.
957
- <div className="text-xs text-text-muted truncate font-normal">
958
- {mailboxes.find((mb) => mb.uid === message.mailboxUid)?.displayName}
959
- </div>
960
- )}
961
- {isSearching && pendingUids.has(message.uid) ? (
962
- // §_Progressive Results_: "Unresolved encrypted results MUST
963
- // be rendered as skeleton entries in place, not appended on
964
- // arrival." This uid is a Tier 1 metadataOnly guess Tier 2/3
965
- // haven't confirmed (or ruled out) yet.
966
- <div className="flex flex-col gap-1.5 py-0.5">
967
- <Skeleton height="h-3.5" className="w-2/3 rounded-sm" />
968
- <Skeleton height="h-3" className="w-full rounded-sm" />
969
- </div>
970
- ) : (
971
- <>
972
- <div className="text-sm truncate">
973
- {decryptedRows[message.uid]?.subject ||
974
- (message.subject === ENCRYPTED_SUBJECT_PLACEHOLDER
975
- ? "Encrypted message"
976
- : message.subject) ||
977
- "(no subject)"}
978
- </div>
979
- <div className="text-xs text-text-muted truncate font-normal">
980
- {snippets[message.uid] || decryptedRows[message.uid]?.preview || message.bodyPreview}
981
- </div>
982
- </>
983
- )}
984
- </button>
985
- </li>
986
- ))}
987
- </ul>
988
- {hasMore && (
989
- <div ref={sentinelRef} className="p-4 text-center text-xs text-text-muted">
990
- {loadingMore ? "Loading more…" : ""}
991
- </div>
992
- )}
993
- {aggregateFolderType && (
994
- <p className="p-4 text-center text-xs text-text-muted">
995
- Showing the most recent mail from each mailbox. Open a specific mailbox&rsquo;s folder to
996
- see older mail.
997
- </p>
998
- )}
999
- </>
1000
- )}
1001
- </div>
1002
- <div className="hidden md:flex flex-1 min-w-0">
1003
- {viewMode === "conversation" ? (
1004
- <ConversationThreadPane
1005
- conversation={selectedConversation}
1006
- folders={mailboxFolders.find((mf) => mf.mailbox.uid === activeMailboxUid)?.folders ?? []}
1007
- labels={labels}
1008
- />
1009
- ) : (
1010
- <MessageDetailPane
1011
- message={selected}
1012
- attachments={attachments}
1013
- isSentItems={isSentItems}
1014
- onRecalled={(updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m)))}
1015
- isOutbox={isOutbox}
1016
- isInbox={isInbox}
1017
- onClassified={(updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m)))}
1018
- onReceiptHandled={(updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m)))}
1019
- draftsFolderUid={draftsFolderUid}
1020
- onScheduledSendCanceled={(updated) => {
1021
- // The message moved out of the currently-viewed Outbox folder (into Drafts)
1022
- // — unlike a recall, which patches a message in place, this removes it from
1023
- // the list entirely, matching what a real folder switch would show.
1024
- setMessages((prev) => prev.filter((m) => m.uid !== updated.uid));
1025
- setSelectedUid(null);
1026
- }}
1027
- onArchived={(updated) => {
1028
- // Same reasoning as onScheduledSendCanceled above — the message moved out of
1029
- // whichever folder is currently being viewed (into Archive), so it's removed
1030
- // from the list rather than patched in place.
1031
- setMessages((prev) => prev.filter((m) => m.uid !== updated.uid));
1032
- setSelectedUid(null);
1033
- }}
1034
- labels={labels}
1035
- onLabelsChanged={(updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m)))}
1036
- />
1037
- )}
1038
- </div>
1039
- </div>
1040
- );
1041
- }
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 { HiOutlineLockClosed } from "react-icons/hi2";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { Mailbox, Message, MessageClassification, getMessage, getMessageRawContent, listMessages } from "@rapidmx/react-shared/mail/mailApi.js";
9
+ import { Label, listLabels } from "@rapidmx/react-shared/mail/labelsApi.js";
10
+ import { ConversationSummary, listConversations } from "@rapidmx/react-shared/mail/conversationsApi.js";
11
+ import { SearchResult, search as searchMailbox } from "@rapidmx/react-shared/search/searchApi.js";
12
+ import { parseSearchQuery, type ParsedSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
13
+ import { normalizeServerScores } from "@rapidmx/react-shared/search/searchScoring.js";
14
+ import { searchEncryptedCandidates } from "@rapidmx/react-shared/search/searchTier3.js";
15
+ import { searchLocalIndex } from "../shared/search/searchTier2.js";
16
+ import type { Coverage } from "../shared/search/localIndexWorker.js";
17
+ import { getUnlockedKeys, subscribeKeySession, UnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
18
+ import { evaluateMessageSecurity } from "@rapidmx/react-shared/crypto/messageSecurity.js";
19
+ import { useMarkMessageRead, useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
20
+ import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
21
+ import MailShell, {
22
+ AggregateFolderType,
23
+ MailboxFolders,
24
+ MailShellProps,
25
+ useMailShell,
26
+ } from "../shared/components/mail/layout/MailShell.js";
27
+ import MessageDetailPane from "../shared/components/mail/MessageDetailPane.js";
28
+ import ConversationList from "../shared/components/mail/ConversationList.js";
29
+ import ConversationThreadPane from "../shared/components/mail/ConversationThreadPane.js";
30
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
31
+ import Skeleton from "@rapidmx/react-shared/components/feedback/Skeleton.js";
32
+ import { useUnlockPrompt } from "../shared/components/layout/UnlockPromptProvider.js";
33
+
34
+ type ViewMode = "date" | "conversation";
35
+
36
+ const MESSAGE_PAGE_SIZE = 50;
37
+ const SEARCH_DEBOUNCE_MS = 300;
38
+ const LIST_PREVIEW_MAX_LENGTH = 160;
39
+
40
+ /** The literal outer-envelope `Subject` every encrypted message carries server-side - RFC 9788's
41
+ * `hcp_baseline` policy obscures it to this exact string (see `smimeMessage.ts`'s
42
+ * `applyBaselineOuterHeaders()`), which is also all `Message.subject` ever shows for one of these until
43
+ * decrypted client-side. Used here to recognize which loaded rows are worth decrypting for display. */
44
+ const ENCRYPTED_SUBJECT_PLACEHOLDER = "[...]";
45
+
46
+ /** A row's client-recovered subject/preview, once decrypted - `undefined` fields mean nothing better
47
+ * than the placeholder/blank server value was recoverable for that field specifically. */
48
+ interface DecryptedRow {
49
+ subject?: string;
50
+ preview?: string;
51
+ }
52
+
53
+ /** Strips HTML down to plain text, for a short list-row preview of a decrypted body - mirrors
54
+ * `searchTier3.ts`'s own private `stripHtml()` (not currently exported from `@rapidmx/react-shared`,
55
+ * so duplicated here rather than pulled in through a package change just for this one small, pure
56
+ * helper). Not a security boundary - the output only ever feeds plain text display, truncated below,
57
+ * never rendered back into any DOM. */
58
+ function stripHtmlToText(html: string): string {
59
+ return html
60
+ .replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, " ")
61
+ .replace(/<[^>]+>/g, " ")
62
+ .replace(/&nbsp;/gi, " ")
63
+ .replace(/&amp;/gi, "&")
64
+ .replace(/&lt;/gi, "<")
65
+ .replace(/&gt;/gi, ">")
66
+ .replace(/&quot;/gi, '"')
67
+ .replace(/&#0*39;/gi, "'")
68
+ .replace(/\s+/g, " ")
69
+ .trim();
70
+ }
71
+
72
+ /**
73
+ * Decrypts the subject/preview of every currently-loaded row whose subject is still the RFC 9788
74
+ * placeholder (i.e. every encrypted message this device hasn't already resolved), keyed by uid - the
75
+ * inbox-list counterpart to `MessageDetailPane`'s own single-message decrypt and `searchTier3.ts`'s
76
+ * per-candidate decrypt. Bounded to `messages` (at most one loaded page, `MESSAGE_PAGE_SIZE`), never the
77
+ * whole mailbox - matching `searchEncryptedCandidates()`'s own "bounded, not everything" scope. Runs only
78
+ * once `unlocked` is available (the caller decides when to call this - see `InboxContent`'s own effect
79
+ * and `handleUnlockList()`), and a single row's fetch/decrypt failure never blocks the rest.
80
+ */
81
+ async function decryptEncryptedRows(messages: Message[], unlocked: UnlockedKeys): Promise<Record<string, DecryptedRow>> {
82
+ const encrypted = messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER);
83
+ const entries = await Promise.all(
84
+ encrypted.map(async (message): Promise<[string, DecryptedRow] | null> => {
85
+ try {
86
+ const rawMime = await getMessageRawContent(message.uid);
87
+ const security = await evaluateMessageSecurity(rawMime, unlocked);
88
+ if (!security.subject && !security.html) {
89
+ return null;
90
+ }
91
+ const preview = security.html ? stripHtmlToText(security.html).slice(0, LIST_PREVIEW_MAX_LENGTH) : undefined;
92
+ return [message.uid, { subject: security.subject, preview }];
93
+ } catch {
94
+ return null;
95
+ }
96
+ }),
97
+ );
98
+ const result: Record<string, DecryptedRow> = {};
99
+ for (const entry of entries) {
100
+ if (entry) {
101
+ result[entry[0]] = entry[1];
102
+ }
103
+ }
104
+ return result;
105
+ }
106
+
107
+ /** Merges Tier 1 (server, possibly `metadataOnly` for an encrypted message), Tier 2 (local index, fully
108
+ * decrypted and re-scored), and Tier 3 (server-narrowed candidates, decrypted and re-scored) results into
109
+ * one ranked list, per `specs/search.md` §7's "client MUST re-score all results it can see... normalise
110
+ * into the same space rather than interleaving raw scores": each tier is normalized independently via
111
+ * `normalizeServerScores()` before merging, since a Postgres/OpenSearch score, a local `bm25()` score,
112
+ * and this module's own Tier 3 term-count score all occupy unrelated ranges. A uid present in more than
113
+ * one list keeps only the last-inserted entry (Tier 2 wins over Tier 3 wins over Tier 1) - Tier 2 and
114
+ * Tier 3 both represent genuine, content-verified scores for the same message, so which one "wins" on
115
+ * overlap doesn't change correctness, only which of two equally-valid scores is shown; either supersedes
116
+ * Tier 1's metadata-only guess for the same uid.
117
+ *
118
+ * Called progressively - once per tier as it resolves, each time with whatever tiers have reported so
119
+ * far (an empty array for the rest) - by `InboxContent`'s own search orchestration below, per §_Progressive
120
+ * Results_' "reordering is permitted and preferred over appending." This function itself stays pure and
121
+ * stateless; it has no notion of "in progress" versus "final." */
122
+ function mergeSearchResults(tier1: SearchResult[], tier2: SearchResult[], tier3: SearchResult[]): SearchResult[] {
123
+ const normalizedTier1 = normalizeServerScores(tier1);
124
+ const normalizedTier2 = normalizeServerScores(tier2);
125
+ const normalizedTier3 = normalizeServerScores(tier3);
126
+ const merged = new Map<string, { result: SearchResult; normalizedScore: number }>();
127
+ for (const entry of normalizedTier1) {
128
+ merged.set(entry.result.entityUid, entry);
129
+ }
130
+ for (const entry of normalizedTier3) {
131
+ merged.set(entry.result.entityUid, entry);
132
+ }
133
+ for (const entry of normalizedTier2) {
134
+ merged.set(entry.result.entityUid, entry);
135
+ }
136
+ return Array.from(merged.values())
137
+ .sort((a, b) => b.normalizedScore - a.normalizedScore)
138
+ .map((entry) => entry.result);
139
+ }
140
+
141
+ /** `type:` narrows `entityTypes`; when absent this still defaults to `["message"]` — a non-message hit
142
+ * (contact/calendarEvent/note/task) has no `Message` to resolve via `getMessage()` below and is simply
143
+ * dropped by the same eventually-consistent-index fallback that already existed, rather than rendered
144
+ * (this inbox list only ever shows message rows; a real multi-entity-type results view is a separate,
145
+ * larger UI project outside this pass). Shared by both the fresh-search orchestration and `loadMore()`
146
+ * below, which each build this from the same `ParsedSearchQuery` differently only in `cursor`. */
147
+ function tier1SearchParams(parsed: ParsedSearchQuery, cursor: string | undefined, mailboxUid: string) {
148
+ return {
149
+ // The mailbox actually open - omitted, the server searches the caller's *own* mailbox, which is the
150
+ // wrong one whenever a shared mailbox's folder is being viewed.
151
+ mailboxUid,
152
+ types: parsed.entityTypes ?? ["message"],
153
+ cursor,
154
+ limit: MESSAGE_PAGE_SIZE,
155
+ from: parsed.from,
156
+ to: parsed.to,
157
+ cc: parsed.cc,
158
+ subject: parsed.subject,
159
+ hasAttachment: parsed.hasAttachment,
160
+ before: parsed.before,
161
+ after: parsed.after,
162
+ folderUid: parsed.folderUid,
163
+ flags: parsed.flags,
164
+ labels: parsed.labels,
165
+ };
166
+ }
167
+
168
+ /** How many of Tier 1's own `metadataOnly` hits (§7: "an encrypted entity matched only on server-visible
169
+ * metadata... MUST be rendered as skeleton entries in place... using the metadata score as a provisional
170
+ * position") are shown as skeleton rows at once - §_Progressive Results_' "Skeletons MUST be capped, at
171
+ * approximately one and a half pages." A non-`metadataOnly` Tier 1 hit (a real, already-scored content
172
+ * match — always the case for unencrypted mail) is never a skeleton and is never subject to this cap.
173
+ *
174
+ * A Tier 3 candidate that Tier 1 did *not* already surface has no provisional score/position of its own
175
+ * under the spec's own wording above, so it is deliberately never pre-rendered as a skeleton here either
176
+ * - it simply appears, fully resolved, once Tier 3 confirms it (see `InboxContent`'s search
177
+ * orchestration). */
178
+ const SKELETON_CAP = Math.round(MESSAGE_PAGE_SIZE * 1.5);
179
+
180
+ function capSkeletons(tier1Hits: SearchResult[]): SearchResult[] {
181
+ let skeletonsSeen = 0;
182
+ return tier1Hits.filter((hit) => {
183
+ if (!hit.metadataOnly) {
184
+ return true;
185
+ }
186
+ skeletonsSeen += 1;
187
+ return skeletonsSeen <= SKELETON_CAP;
188
+ });
189
+ }
190
+
191
+ /** How many candidates Tier 3 pulls per distinct search - larger than one page's worth so several
192
+ * `loadMore()` pages can be sliced from one decrypt pass (see `Tier3Cache` below) instead of a second,
193
+ * separately expensive server round trip and re-decrypt for the same query. Bounded, not unlimited - per
194
+ * this module's own `tier1SearchParams()` sibling, Tier 3's own candidate-narrowing is already the
195
+ * "heaviest single client-side cost" tier (`specs/search.md` §9's identical framing for attachment
196
+ * extraction); a query whose true candidate set exceeds this simply pages out once this cache is
197
+ * exhausted; `loadMore()` reflects that honestly via `hasMore`. */
198
+ const TIER3_CANDIDATE_LIMIT = 200;
199
+
200
+ /** One entry per distinct (mailbox, query, "search all mail" toggle, unlocked-or-not) combination this
201
+ * tab has already run Tier 3 for - keyed by `tier3CacheKey()` below. Tier 3's decrypt-and-match pass
202
+ * (`searchEncryptedCandidates()`) is by far this search's most expensive step, so it runs once per
203
+ * combination; every subsequent `loadMore()` page for that same combination slices further into the
204
+ * same already-decrypted array. Session-scoped, per tab, with no explicit eviction - a small map that's
205
+ * simply never read again once the query changes, the same shape `InboxContent`'s own `decryptedRows`
206
+ * state already accepts for a similar "worth keeping around, not worth actively pruning" tradeoff.
207
+ *
208
+ * The unlocked-or-not dimension matters: re-running an identical query right after an on-demand unlock
209
+ * (`handleUnlockSearch()`) MUST NOT reuse the "nothing to contribute" entry that same query cached while
210
+ * still locked - `searchEncryptedCandidates()` degrades to `[]` for an absent `unlocked`, and that empty
211
+ * result is exactly as cacheable/reusable as a real one, just under a different key. */
212
+ type Tier3Cache = Map<string, SearchResult[]>;
213
+
214
+ /** Keyed by the exact query windows Tier 3 actually ran (`tier3Windows()` - which already reflect the query,
215
+ * the "Search all mail" toggle, and Tier 2's coverage at the time), not just the query: the same query
216
+ * narrowed differently (a build finished, new mail moved the coverage end) must not reuse a result that was
217
+ * computed over a different date range. */
218
+ function tier3CacheKey(mailboxUid: string, windows: ParsedSearchQuery[], unlocked: boolean): string {
219
+ return `${mailboxUid}|${JSON.stringify(windows)}|${String(unlocked)}`;
220
+ }
221
+
222
+ /** A search query's cache/cursor identity - stable across re-parsing the identical raw text, and
223
+ * distinct for anything else (§8's "a query fingerprint, so a cursor cannot be replayed against a
224
+ * different query"). `JSON.stringify` on `ParsedSearchQuery` is deterministic here because every one of
225
+ * its own fields is a primitive or a `Date` (which serializes to a fixed ISO string) - no nested object
226
+ * whose key order could vary between two structurally-identical parses of the same text. */
227
+ function queryFingerprint(parsed: ParsedSearchQuery): string {
228
+ return JSON.stringify(parsed);
229
+ }
230
+
231
+ /** The date windows Tier 3 still has to search: the query's own range minus Tier 2's guaranteed coverage
232
+ * `[coverage.indexedFrom, coverage.indexedUntil]`, unless the reader explicitly asked to "Search all mail".
233
+ * Tier 2 already holds fully-decrypted, current content for that range, so re-fetching and re-decrypting it
234
+ * through Tier 3's slower candidate-narrowing path would be pure waste. Yields up to two windows - older than
235
+ * the coverage (`before:` tightened to `indexedFrom`) and newer than it (`after:` raised to `indexedUntil`,
236
+ * since nothing indexes mail that arrived after the build pass started) - never *widening* a bound the query
237
+ * already specified, and none at all when the query lies entirely inside the coverage.
238
+ *
239
+ * Only narrows once Tier 2 reports a finished, complete build pass from this session: while it's still
240
+ * building (or a pass stopped early - a failed folder listing, the byte budget) `indexedFrom` is just the
241
+ * oldest row that happens to be present, not a guarantee every encrypted message since then is indexed, and
242
+ * narrowing on it would silently drop encrypted results neither tier returns. */
243
+ function tier3Windows(parsed: ParsedSearchQuery, coverage: Coverage | undefined, searchAllMail: boolean): ParsedSearchQuery[] {
244
+ if (searchAllMail || !coverage?.indexedFrom || !coverage.indexedUntil || coverage.building || !coverage.complete) {
245
+ return [parsed];
246
+ }
247
+ const coveredFrom = new Date(coverage.indexedFrom);
248
+ const coveredUntil = new Date(coverage.indexedUntil);
249
+ const windows: ParsedSearchQuery[] = [];
250
+ if (!parsed.after || parsed.after.getTime() < coveredFrom.getTime()) {
251
+ windows.push({ ...parsed, before: parsed.before && parsed.before.getTime() < coveredFrom.getTime() ? parsed.before : coveredFrom });
252
+ }
253
+ if (!parsed.before || parsed.before.getTime() > coveredUntil.getTime()) {
254
+ windows.push({ ...parsed, after: parsed.after && parsed.after.getTime() > coveredUntil.getTime() ? parsed.after : coveredUntil });
255
+ }
256
+ return windows;
257
+ }
258
+
259
+ /** Runs Tier 3 over each window and merges the candidates (a uid can't match in two disjoint windows, but a
260
+ * message whose date sits exactly on a boundary may come back from both). */
261
+ async function searchTier3Windows(windows: ParsedSearchQuery[], unlocked: UnlockedKeys | undefined, mailboxUid: string): Promise<SearchResult[]> {
262
+ const pages = await Promise.all(
263
+ windows.map((window) => searchEncryptedCandidates(window, unlocked, TIER3_CANDIDATE_LIMIT, { mailboxUid })),
264
+ );
265
+ const merged = new Map<string, SearchResult>();
266
+ for (const result of pages.flat()) {
267
+ if (!merged.has(result.entityUid)) {
268
+ merged.set(result.entityUid, result);
269
+ }
270
+ }
271
+ return [...merged.values()];
272
+ }
273
+
274
+ /** The paging state for one search, composited across all three tiers (`specs/search.md` §8) - opaque to
275
+ * every caller the same way `SearchResultPage.nextCursor` is opaque to callers of `search()` itself.
276
+ * Tier 1 keeps the server's own opaque cursor unmodified; Tier 2 (the local index) and Tier 3 (the
277
+ * cached, already-decrypted candidate array - see `Tier3Cache` above) are both this client's own state,
278
+ * so their "position" is just a plain offset into each. */
279
+ interface CompositeCursor {
280
+ tier1Cursor?: string;
281
+ tier2Offset: number;
282
+ tier3Offset: number;
283
+ /** The `Tier3Cache` entry the first page was sliced from - later pages keep slicing the same one. */
284
+ tier3Key: string;
285
+ fingerprint: string;
286
+ }
287
+
288
+ /** `undefined` for a missing, corrupted, or foreign-query cursor - every caller already treats "no
289
+ * cursor" as "start this tier from the beginning," so there's no separate error path needed here. */
290
+ function decodeCursor(raw: CompositeCursor | undefined, fingerprint: string): CompositeCursor | undefined {
291
+ return raw?.fingerprint === fingerprint ? raw : undefined;
292
+ }
293
+
294
+ /** Resolves every hit's `entityUid` to a full `Message` via `getMessage()`, reusing `cache` across
295
+ * repeated calls within the same search pass - `InboxContent`'s own search orchestration below re-merges
296
+ * and re-resolves the *entire* current hit set each time a tier resolves, so without this cache every
297
+ * stage would re-fetch messages an earlier stage already fetched. A hit whose message no longer resolves
298
+ * (deleted after being indexed, or the fetch itself failed) is cached as `null` and dropped - the same
299
+ * "a search hit can briefly outlive the message it points to" tolerance this function's inline
300
+ * predecessor already had. */
301
+ async function resolveHitsToMessages(
302
+ hits: SearchResult[],
303
+ cache: Map<string, Message | null>,
304
+ ): Promise<{ messages: Message[]; snippets: Record<string, string> }> {
305
+ const toFetch = hits.filter((hit) => !cache.has(hit.entityUid));
306
+ await Promise.all(
307
+ toFetch.map(async (hit) => {
308
+ const message = await getMessage(hit.entityUid).catch(() => null);
309
+ cache.set(hit.entityUid, message);
310
+ }),
311
+ );
312
+ const messages: Message[] = [];
313
+ const snippets: Record<string, string> = {};
314
+ for (const hit of hits) {
315
+ const message = cache.get(hit.entityUid);
316
+ if (!message) {
317
+ continue;
318
+ }
319
+ messages.push(message);
320
+ if (hit.snippet) {
321
+ snippets[message.uid] = hit.snippet;
322
+ }
323
+ }
324
+ return { messages, snippets };
325
+ }
326
+
327
+ /** How far outside the scroll container's visible area the load-more sentinel still counts as "in view". */
328
+ const LOAD_MORE_ROOT_MARGIN_PX = 200;
329
+
330
+ /** How many full pages in a row that added no new rows the list keeps loading on its own before it shows a
331
+ * "Load more" button instead. */
332
+ const MAX_EMPTY_PAGE_CONTINUATIONS = 3;
333
+
334
+ /** `true` when `more` has at least one uid `shown` doesn't - i.e. appending it actually adds rows. */
335
+ function hasUnseenMessages(shown: Message[], more: Message[]): boolean {
336
+ const seen = new Set(shown.map((m) => m.uid));
337
+ return more.some((m) => !seen.has(m.uid));
338
+ }
339
+
340
+ /** The load-more sentinel's actual current geometry against its scroll container, with the same margin the
341
+ * IntersectionObserver uses. */
342
+ function isWithinLoadMoreRange(sentinel: HTMLElement, root: HTMLElement): boolean {
343
+ const rect = sentinel.getBoundingClientRect();
344
+ const rootRect = root.getBoundingClientRect();
345
+ return rect.top <= rootRect.bottom + LOAD_MORE_ROOT_MARGIN_PX && rect.bottom >= rootRect.top - LOAD_MORE_ROOT_MARGIN_PX;
346
+ }
347
+
348
+ /** Appends `more` to `shown`, skipping any uid already shown - a later page can repeat rows (Tier 1 and
349
+ * Tier 2/3 cursors advance independently, so the same message can come back from a different tier on a
350
+ * later page; a plain folder listing's pages shift when new mail arrives between fetches). */
351
+ function appendUnseenMessages(shown: Message[], more: Message[]): Message[] {
352
+ const seen = new Set(shown.map((m) => m.uid));
353
+ const unseen: Message[] = [];
354
+ for (const message of more) {
355
+ if (!seen.has(message.uid)) {
356
+ seen.add(message.uid);
357
+ unseen.push(message);
358
+ }
359
+ }
360
+ return unseen.length === 0 ? shown : [...shown, ...unseen];
361
+ }
362
+
363
+ /** Flattens and sorts a per-mailbox fetch into one merged, newest-first list - the aggregate ("All
364
+ * Inboxes" etc.) equivalent of `mergeSearchResults()` above, but simpler: an aggregated message has no
365
+ * natural relevance score to normalize, so this only ever sorts by `receivedDate`. */
366
+ function mergeInboxMessages(perMailbox: { mailbox: Mailbox; messages: Message[] }[]): Message[] {
367
+ return perMailbox
368
+ .flatMap((entry) => entry.messages)
369
+ .sort((a, b) => new Date(b.receivedDate).getTime() - new Date(a.receivedDate).getTime());
370
+ }
371
+
372
+ /**
373
+ * Fans out one `listMessages()` call per accessible mailbox that has a folder of `type`, merges the
374
+ * results newest-first. A mailbox with no matching folder, or whose fetch fails, simply contributes
375
+ * nothing - one mailbox's absence/failure must not blank out every other mailbox's messages.
376
+ *
377
+ * **Pagination scope trim (deliberate, matching this file's own documented Tier 2/3 tradeoffs)**: there is
378
+ * no composite cursor across an arbitrary number of independently-paginated mailboxes in this pass - this
379
+ * always fetches exactly each mailbox's own first page (`MESSAGE_PAGE_SIZE`) and the caller never offers a
380
+ * "load more" for the result (see `InboxContent`'s own `hasMore` handling in aggregate mode) - a real
381
+ * composite-cursor "load more" per mailbox is a natural v2 if usage shows people scrolling past the first
382
+ * page in aggregate view often.
383
+ */
384
+ async function fetchAggregateMessages(mailboxFolders: MailboxFolders[], type: AggregateFolderType): Promise<Message[]> {
385
+ const perMailbox = await Promise.all(
386
+ mailboxFolders.map(async ({ mailbox, folders }) => {
387
+ const folder = folders.find((f) => f.type === type);
388
+ if (!folder) {
389
+ return { mailbox, messages: [] as Message[] };
390
+ }
391
+ const messages = await listMessages(folder.uid, { limit: MESSAGE_PAGE_SIZE }).catch(() => [] as Message[]);
392
+ return { mailbox, messages };
393
+ }),
394
+ );
395
+ return mergeInboxMessages(perMailbox);
396
+ }
397
+
398
+ export default function InboxPage(props: MailShellProps) {
399
+ return (
400
+ <MailShell {...props}>
401
+ <InboxContent userUid={props.userUid} />
402
+ </MailShell>
403
+ );
404
+ }
405
+
406
+ function InboxContent({ userUid }: { userUid?: string }) {
407
+ const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType } = useMailShell();
408
+ const isMobile = useIsMobile();
409
+ const { requestUnlock } = useUnlockPrompt();
410
+ const [viewMode, setViewMode] = useState<ViewMode>("date");
411
+ const [messages, setMessages] = useState<Message[]>([]);
412
+ const [conversations, setConversations] = useState<ConversationSummary[]>([]);
413
+ const [loading, setLoading] = useState(true);
414
+ const [loadingMore, setLoadingMore] = useState(false);
415
+ const [hasMore, setHasMore] = useState(false);
416
+ const [error, setError] = useState<string | null>(null);
417
+ const [selectedUid, setSelectedUid] = useState<string | null>(null);
418
+ const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null);
419
+ const [classificationFilter, setClassificationFilter] = useState<MessageClassification | "all">("all");
420
+ const [searchInput, setSearchInput] = useState("");
421
+ const [searchQuery, setSearchQuery] = useState("");
422
+ const [snippets, setSnippets] = useState<Record<string, string>>({});
423
+ const [labels, setLabels] = useState<Label[]>([]);
424
+ // Tier 2's own reported window coverage for the current search - undefined outside a search, or
425
+ // before Tier 2 has resolved yet for this search pass.
426
+ const [coverage, setCoverage] = useState<Coverage | undefined>(undefined);
427
+ // Keyed by message uid - see decryptEncryptedRows(). Never cleared on folder/search switches (a
428
+ // decrypted row stays decrypted while its keys stay unlocked; re-decrypting on every navigation would
429
+ // waste work for no benefit) - only cleared when the mailbox's keys are locked (see the
430
+ // `subscribeKeySession()` effect below).
431
+ const [decryptedRows, setDecryptedRows] = useState<Record<string, DecryptedRow>>({});
432
+ // Bumped after a successful on-demand unlock to re-run the search effect below - it's not a
433
+ // dependency the effect could otherwise react to (getUnlockedKeys() is a plain module-level read, not
434
+ // React state; see keySession.ts's own doc comment).
435
+ const [unlockRefresh, setUnlockRefresh] = useState(0);
436
+ // §_Progressive Results_: which of the current search's uids are still an unconfirmed Tier 1
437
+ // `metadataOnly` guess (rendered as a skeleton row - see the JSX below) - empty outside a search, and
438
+ // always empty again once every tier has reported for the current pass (each unresolved entry is by
439
+ // then either confirmed, real content, or pruned - see the search orchestration effect).
440
+ const [pendingUids, setPendingUids] = useState<Set<string>>(new Set());
441
+ // Withholds a hard result count until every tier has reported for the current search pass (§_Progressive
442
+ // Results_: "Never show a hard count until every tier has reported... A settled count is the signal
443
+ // that ordering is final"). Reset on every fresh search; irrelevant outside search mode.
444
+ const [tier1Done, setTier1Done] = useState(false);
445
+ const [tier2Done, setTier2Done] = useState(false);
446
+ const [tier3Done, setTier3Done] = useState(false);
447
+ // Toggled by the "Search all mail" action next to the results count - removes Tier 3's own default
448
+ // bound (tightened to Tier 2's coverage window otherwise - see tightenBeforeToCoverage()) for one
449
+ // re-run. Stored as the mailbox/folder/query it was requested for, not a plain flag, so it resets the
450
+ // moment any of those change - derived in the same render, so the search effect never runs a new
451
+ // query with a previous query's unbounded Tier 3 window first.
452
+ const [searchAllMailKey, setSearchAllMailKey] = useState<string | null>(null);
453
+ const searchAllMailScope = `${mailboxUid ?? ""}\n${folderUid ?? ""}\n${searchQuery}`;
454
+ const searchAllMail = searchAllMailKey === searchAllMailScope;
455
+ // Search stays real-folder-only - Tier 1/2/3 are all deeply mailbox/folder-scoped, and extending them
456
+ // to span an arbitrary number of mailboxes is out of scope for this pass (see the aggregate-fetch
457
+ // branch below, which the search effect never reaches while `folderUid` is unset).
458
+ const isSearching = viewMode === "date" && searchQuery.length > 0 && !aggregateFolderType;
459
+ // How far into the folder's *current* server-side listing the rows fetched so far reach. Offset
460
+ // paging, not a cursor (`listMessages()` has none): a row removed locally (archived, scheduled send
461
+ // cancelled) also left the folder server-side, shifting every later message back by one - so each
462
+ // removal steps this back too, or the next page would silently skip a message. See `loadMore()`.
463
+ const listedOffsetRef = useRef(0);
464
+ const scrollContainerRef = useRef<HTMLDivElement | null>(null);
465
+ // State (via a callback ref), not a plain ref: the sentinel mounts and unmounts as the list loads,
466
+ // filters, and empties, and the observer effect below must re-attach to whichever node is current.
467
+ const [sentinel, setSentinel] = useState<HTMLDivElement | null>(null);
468
+ const [loadMoreError, setLoadMoreError] = useState<string | null>(null);
469
+ const loadMoreErrorRef = useRef<string | null>(null);
470
+ loadMoreErrorRef.current = loadMoreError;
471
+ const loadMoreInFlightRef = useRef(false);
472
+ // Bumped by `loadMore()` for each page that added at least one row - the only event the continuation
473
+ // effect below keeps loading after. A counter rather than watching `loadingMore` flip back to false: a
474
+ // fast response can settle before React ever renders the `true`, so that flip isn't reliably observable.
475
+ const [appendedPageCount, setAppendedPageCount] = useState(0);
476
+ // Consecutive full pages that added no new rows (every row was already shown - e.g. new mail shifted the
477
+ // folder's pages). Such a page still counts as progress for the continuation effect, up to
478
+ // `MAX_EMPTY_PAGE_CONTINUATIONS` in a row; past that, `loadMoreStalled` shows a "Load more" button
479
+ // instead, so a server that keeps repeating rows is never looped on.
480
+ const emptyPageStreakRef = useRef(0);
481
+ const [loadMoreStalled, setLoadMoreStalled] = useState(false);
482
+ const messagesRef = useRef(messages);
483
+ messagesRef.current = messages;
484
+ // The mailbox unlock/decrypt call sites below treat as "the" mailbox when there's no single selected
485
+ // one (aggregate mode) - mirrors `MailShell`'s own identical `defaultMailboxUid` fallback. An
486
+ // aggregate-view row from a *different*, not-yet-unlocked mailbox stays locked until that mailbox's
487
+ // own folder view is opened directly - an accepted limitation, not a bug (see `MailShell`'s own doc
488
+ // comment on the same tradeoff for its `LocalIndexLifecycle`/`KeyEnrollmentGate` wiring).
489
+ const activeMailboxUid = mailboxUid ?? mailboxes.find((mb) => mb.ownerUserUid === userUid)?.uid ?? mailboxes[0]?.uid;
490
+ const mailboxKeys = mailboxes.find((mb) => mb.uid === activeMailboxUid)?.keys ?? [];
491
+ // Reset to a fresh Map at the start of every new search pass (see the search effect below) - see
492
+ // resolveHitsToMessages()'s own doc comment on why this needs to persist *within* one pass but not
493
+ // across passes (a stale `null` for a uid that's since become resolvable elsewhere must not stick).
494
+ const resolvedMessageCacheRef = useRef<Map<string, Message | null>>(new Map());
495
+ // Session-scoped, never explicitly cleared - see Tier3Cache's own doc comment above.
496
+ const tier3CacheRef = useRef<Tier3Cache>(new Map());
497
+ // The latest composite cursor this search pass has reached - read by loadMore(), written at the end
498
+ // of both the fresh-search orchestration and loadMore() itself. Not React state: it never drives a
499
+ // render on its own, only what loadMore() does with it later.
500
+ const compositeCursorRef = useRef<CompositeCursor | undefined>(undefined);
501
+ // Guards every async load below - each search stage, the plain folder/conversation/aggregate listings,
502
+ // and `loadMore()` - against a stale, still-in-flight pass clobbering state for a newer one that started
503
+ // after it (the query, folder, or view changed) - the same `loadSeq`-style monotonic-id pattern already used elsewhere in this codebase (e.g.
504
+ // `settings/privacy/index.tsx`'s `ExportSection`), generalized here across three independently-timed
505
+ // async stages instead of one.
506
+ const searchRunIdRef = useRef(0);
507
+ // Bumped whenever `activeMailboxUid`'s keys are locked (see the `subscribeKeySession()` effect below) - a
508
+ // decrypt that started before the lock checks it before storing its now-stale plaintext rows.
509
+ const lockGenerationRef = useRef(0);
510
+ // `messages` themselves aren't a dependency here on purpose - a message uid, once decrypted, is
511
+ // never re-decrypted just because the list re-renders with the same rows (e.g. a folder-unrelated
512
+ // state update elsewhere). New rows (a fresh page load, load-more, or a completed search) each
513
+ // re-trigger this the normal way, by changing `messages` itself.
514
+ //
515
+ // Scoped to `activeMailboxUid`'s own rows only - in aggregate mode `messages` can span several
516
+ // mailboxes, but only one mailbox's keys are ever being unlocked/tracked here (see `activeMailboxUid`'s
517
+ // own doc comment above); an encrypted row from any other mailbox simply isn't a candidate for this
518
+ // auto-decrypt or the manual unlock banner below.
519
+ const undecryptedEncryptedUids = messages
520
+ .filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER && !decryptedRows[m.uid] && m.mailboxUid === activeMailboxUid)
521
+ .map((m) => m.uid);
522
+
523
+ // Once unlocked, silently decrypt this page's own encrypted rows to show their real subject/preview -
524
+ // no prompt needed here, the same way searchEncryptedCandidates() already auto-includes decrypted
525
+ // matches once unlocked without asking again. Only the *first* unlock (or a fresh page of messages
526
+ // arriving) needs this; `handleUnlockList()` below covers the not-yet-unlocked case explicitly.
527
+ useEffect(() => {
528
+ if (undecryptedEncryptedUids.length === 0 || !activeMailboxUid) {
529
+ return;
530
+ }
531
+ const unlocked = getUnlockedKeys(activeMailboxUid);
532
+ if (!unlocked) {
533
+ return;
534
+ }
535
+ let cancelled = false;
536
+ const lockGeneration = lockGenerationRef.current;
537
+ void decryptEncryptedRows(
538
+ messages.filter((m) => undecryptedEncryptedUids.includes(m.uid)),
539
+ unlocked,
540
+ ).then((decrypted) => {
541
+ // A lock while this was in flight already cleared `decryptedRows` - never put them back.
542
+ if (!cancelled && lockGeneration === lockGenerationRef.current && Object.keys(decrypted).length > 0) {
543
+ setDecryptedRows((prev) => ({ ...prev, ...decrypted }));
544
+ }
545
+ });
546
+ return () => {
547
+ cancelled = true;
548
+ };
549
+ }, [messages, unlockRefresh]);
550
+
551
+ // Locking a mailbox's keys (idle timeout, "Destroy keys on this device now", sign-out) must take its
552
+ // decrypted content off screen too, not just out of memory: decrypted subjects/previews, search
553
+ // snippets (Tier 2/3 snippets are decrypted content), and the already-decrypted Tier 3 candidates. A
554
+ // live search re-runs, so Tier 2/3 contribute nothing again until the user unlocks. Read through a ref
555
+ // so the subscription itself doesn't churn on every render.
556
+ const keyLockStateRef = useRef({ activeMailboxUid, isSearching });
557
+ keyLockStateRef.current = { activeMailboxUid, isSearching };
558
+ useEffect(
559
+ () =>
560
+ subscribeKeySession(({ mailboxUid: changedMailboxUid, state }) => {
561
+ const current = keyLockStateRef.current;
562
+ if (state !== "locked" || changedMailboxUid !== current.activeMailboxUid) {
563
+ return;
564
+ }
565
+ lockGenerationRef.current += 1;
566
+ setDecryptedRows({});
567
+ setSnippets({});
568
+ tier3CacheRef.current.clear();
569
+ if (current.isSearching) {
570
+ setUnlockRefresh((n) => n + 1);
571
+ }
572
+ }),
573
+ [],
574
+ );
575
+
576
+ async function handleUnlockList() {
577
+ try {
578
+ const unlocked = await requestUnlock(activeMailboxUid, mailboxKeys);
579
+ const lockGeneration = lockGenerationRef.current;
580
+ const decrypted = await decryptEncryptedRows(
581
+ messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER && m.mailboxUid === activeMailboxUid),
582
+ unlocked,
583
+ );
584
+ if (lockGeneration === lockGenerationRef.current) {
585
+ setDecryptedRows((prev) => ({ ...prev, ...decrypted }));
586
+ }
587
+ } catch {
588
+ // User dismissed the unlock dialog - rows stay exactly as they were.
589
+ }
590
+ }
591
+
592
+ async function handleUnlockSearch() {
593
+ try {
594
+ await requestUnlock(activeMailboxUid, mailboxKeys);
595
+ setUnlockRefresh((n) => n + 1);
596
+ } catch {
597
+ // User dismissed the unlock dialog - the search results stay exactly as they were.
598
+ }
599
+ }
600
+
601
+ // Labels are mailbox-wide, not folder-scoped - fetched once per mailbox rather than per message, and
602
+ // handed to the detail pane below. A failure here just means the Labels control stays hidden (an empty
603
+ // `labels` array) rather than blocking the rest of the inbox. Keyed on the *selected message's own*
604
+ // mailbox: aggregate views and search results can show messages from a mailbox other than
605
+ // `activeMailboxUid`, and offering that mailbox's labels would let a label from the wrong mailbox be
606
+ // applied. The conversation view stays scoped to `activeMailboxUid`, the mailbox its threads come from.
607
+ const selectedMessageMailboxUid = messages.find((m) => m.uid === selectedUid)?.mailboxUid;
608
+ const labelsMailboxUid = viewMode === "date" ? (selectedMessageMailboxUid ?? activeMailboxUid) : activeMailboxUid;
609
+ // `labelsMailboxUid` is always set: `MailShell` only renders this component once at least one mailbox
610
+ // exists, so `activeMailboxUid` (its fallback) always resolves.
611
+ useEffect(() => {
612
+ let cancelled = false;
613
+ setLabels([]);
614
+ listLabels(labelsMailboxUid, { limit: 200 })
615
+ .then((result) => {
616
+ if (!cancelled) {
617
+ setLabels(result);
618
+ }
619
+ })
620
+ .catch(() => undefined);
621
+ return () => {
622
+ cancelled = true;
623
+ };
624
+ }, [labelsMailboxUid]);
625
+
626
+ // Debounce the raw input into the query actually searched, so every keystroke doesn't fire a request.
627
+ useEffect(() => {
628
+ const handle = setTimeout(() => setSearchQuery(searchInput.trim()), SEARCH_DEBOUNCE_MS);
629
+ return () => clearTimeout(handle);
630
+ }, [searchInput]);
631
+
632
+ // Conversations are computed mailbox-wide (see `conversationsApi.ts`), not scoped to the selected
633
+ // folder — switching into "By conversation" mode replaces the per-folder list entirely, and the
634
+ // folder-tree sidebar's own selection becomes purely informational until switching back to "By date".
635
+ useEffect(() => {
636
+ setSelectedUid(null);
637
+ setSelectedConversationId(null);
638
+ setClassificationFilter("all");
639
+ listedOffsetRef.current = 0;
640
+ compositeCursorRef.current = undefined;
641
+ setHasMore(false);
642
+ setLoadMoreError(null);
643
+ setLoadMoreStalled(false);
644
+ emptyPageStreakRef.current = 0;
645
+ // Every run - search or not - supersedes whatever an earlier run (or a `loadMore()` it started)
646
+ // still has in flight; each async callback below checks this before touching state.
647
+ searchRunIdRef.current += 1;
648
+ const myRunId = searchRunIdRef.current;
649
+ const isCurrentRun = () => searchRunIdRef.current === myRunId;
650
+ // Also invalidates on unmount, so nothing lands after the component is gone. Unconditional: this
651
+ // cleanup only ever runs for the latest run of this effect (React runs it before the next run
652
+ // bumps the id, or on unmount), so the id is always still `myRunId` here.
653
+ const invalidate = () => {
654
+ searchRunIdRef.current += 1;
655
+ };
656
+
657
+ if (viewMode === "conversation") {
658
+ // Conversations stay single-mailbox (not aggregated across mailboxes in this pass) - in
659
+ // aggregate mode this falls back to `activeMailboxUid`, the same mailbox unlock/labels use
660
+ // (always set - `MailShell` only renders this component once at least one mailbox exists).
661
+ setLoading(true);
662
+ setError(null);
663
+ listConversations(activeMailboxUid)
664
+ .then((result) => {
665
+ if (isCurrentRun()) {
666
+ setConversations(result);
667
+ }
668
+ })
669
+ .catch((err) => {
670
+ if (isCurrentRun()) {
671
+ setError(err instanceof ApiRequestError ? err.message : "Could not load conversations.");
672
+ }
673
+ })
674
+ .finally(() => {
675
+ if (isCurrentRun()) {
676
+ setLoading(false);
677
+ }
678
+ });
679
+ return invalidate;
680
+ }
681
+
682
+ if (aggregateFolderType) {
683
+ setLoading(true);
684
+ setError(null);
685
+ // hasMore stays false (set above) - see fetchAggregateMessages()'s own pagination scope trim.
686
+ void fetchAggregateMessages(mailboxFolders, aggregateFolderType)
687
+ .then((results) => {
688
+ if (isCurrentRun()) {
689
+ setMessages(results);
690
+ }
691
+ })
692
+ .finally(() => {
693
+ if (isCurrentRun()) {
694
+ setLoading(false);
695
+ }
696
+ });
697
+ return invalidate;
698
+ }
699
+
700
+ if (!folderUid) {
701
+ setMessages([]);
702
+ setLoading(false);
703
+ return;
704
+ }
705
+ setLoading(true);
706
+ setError(null);
707
+
708
+ if (isSearching) {
709
+ setCoverage(undefined);
710
+ setPendingUids(new Set());
711
+ setTier1Done(false);
712
+ setTier2Done(false);
713
+ setTier3Done(false);
714
+ resolvedMessageCacheRef.current = new Map();
715
+ const parsed = parseSearchQuery(searchQuery);
716
+ const unlocked = getUnlockedKeys(mailboxUid!);
717
+ const fingerprint = queryFingerprint(parsed);
718
+
719
+ // Recomputes and re-renders the merged list from whatever tiers have reported so far -
720
+ // called once after Tier 1+2 (interim: unconfirmed Tier 1 metadataOnly hits render as
721
+ // skeletons) and again after Tier 3 (final: anything still unconfirmed is pruned instead -
722
+ // §_Progressive Results_' "Skeletons resolve or disappear"). Bails out via `myRunId` if a
723
+ // newer search pass has since started.
724
+ async function reveal(tier1Hits: SearchResult[], tier2Hits: SearchResult[], tier3Hits: SearchResult[], final: boolean) {
725
+ const capped = capSkeletons(tier1Hits);
726
+ const confirmed = new Set([...tier2Hits, ...tier3Hits].map((r) => r.entityUid));
727
+ const effectiveTier1 = final ? capped.filter((hit) => !hit.metadataOnly || confirmed.has(hit.entityUid)) : capped;
728
+ const merged = mergeSearchResults(effectiveTier1, tier2Hits, tier3Hits);
729
+ const { messages: resolved, snippets: resolvedSnippets } = await resolveHitsToMessages(
730
+ merged,
731
+ resolvedMessageCacheRef.current,
732
+ );
733
+ if (searchRunIdRef.current !== myRunId) {
734
+ return;
735
+ }
736
+ setMessages(resolved);
737
+ setSnippets(resolvedSnippets);
738
+ if (final) {
739
+ setPendingUids(new Set());
740
+ } else {
741
+ const pending = new Set<string>();
742
+ for (const hit of capped) {
743
+ if (hit.metadataOnly && !confirmed.has(hit.entityUid)) {
744
+ pending.add(hit.entityUid);
745
+ }
746
+ }
747
+ setPendingUids(pending);
748
+ }
749
+ }
750
+
751
+ void (async () => {
752
+ try {
753
+ const [tier1Page, tier2Page] = await Promise.all([
754
+ searchMailbox(parsed.text, tier1SearchParams(parsed, undefined, mailboxUid!)),
755
+ searchLocalIndex(mailboxUid!, parsed, unlocked, MESSAGE_PAGE_SIZE, 0),
756
+ ]);
757
+ if (searchRunIdRef.current !== myRunId) {
758
+ return;
759
+ }
760
+ setTier1Done(true);
761
+ setTier2Done(true);
762
+ setCoverage(tier2Page.coverage);
763
+ setLoading(false);
764
+ await reveal(tier1Page.results, tier2Page.results, [], false);
765
+
766
+ const windows = tier3Windows(parsed, tier2Page.coverage, searchAllMail);
767
+ const cacheKey = tier3CacheKey(mailboxUid!, windows, !!unlocked);
768
+ let tier3Full = tier3CacheRef.current.get(cacheKey);
769
+ if (!tier3Full) {
770
+ tier3Full = await searchTier3Windows(windows, unlocked, mailboxUid!);
771
+ if (searchRunIdRef.current !== myRunId) {
772
+ return;
773
+ }
774
+ tier3CacheRef.current.set(cacheKey, tier3Full);
775
+ }
776
+ const tier3Page = tier3Full.slice(0, MESSAGE_PAGE_SIZE);
777
+ setTier3Done(true);
778
+ compositeCursorRef.current = {
779
+ tier1Cursor: tier1Page.nextCursor,
780
+ tier2Offset: tier2Page.results.length,
781
+ tier3Offset: tier3Page.length,
782
+ tier3Key: cacheKey,
783
+ fingerprint,
784
+ };
785
+ setHasMore(!!tier1Page.nextCursor || tier2Page.hasMore || tier3Page.length < tier3Full.length);
786
+ await reveal(tier1Page.results, tier2Page.results, tier3Page, true);
787
+ } catch (err) {
788
+ if (searchRunIdRef.current === myRunId) {
789
+ setError(err instanceof ApiRequestError ? err.message : "Search failed.");
790
+ setLoading(false);
791
+ }
792
+ }
793
+ })();
794
+ return invalidate;
795
+ }
796
+
797
+ listMessages(folderUid, { limit: MESSAGE_PAGE_SIZE })
798
+ .then((results) => {
799
+ if (isCurrentRun()) {
800
+ setMessages(results);
801
+ listedOffsetRef.current = results.length;
802
+ setHasMore(results.length === MESSAGE_PAGE_SIZE);
803
+ }
804
+ })
805
+ .catch((err) => {
806
+ if (isCurrentRun()) {
807
+ setError(err instanceof ApiRequestError ? err.message : "Could not load messages.");
808
+ }
809
+ })
810
+ .finally(() => {
811
+ if (isCurrentRun()) {
812
+ setLoading(false);
813
+ }
814
+ });
815
+ return invalidate;
816
+ // `unlockRefresh`/`searchAllMail` are dependencies solely so `handleUnlockSearch()`/"Search all
817
+ // mail" can force this effect to re-run the search above - Tier 3 (encrypted) results silently
818
+ // contribute nothing without unlocked keys, so this is what actually makes them appear once the
819
+ // user unlocks, and what makes "Search all mail" actually remove Tier 3's coverage bound. Neither
820
+ // has any effect on the non-search branch below; re-running it with identical inputs just
821
+ // re-fetches the same page.
822
+ }, [viewMode, folderUid, mailboxUid, isSearching, searchQuery, unlockRefresh, searchAllMail, aggregateFolderType, mailboxFolders, activeMailboxUid]);
823
+
824
+ function handleSearchAllMail() {
825
+ setSearchAllMailKey(searchAllMailScope);
826
+ }
827
+
828
+ const loadMore = useCallback(async () => {
829
+ const parsed = parseSearchQuery(searchQuery);
830
+ const fingerprint = queryFingerprint(parsed);
831
+ // While searching, a load-more continues from this query's own cursor - absent until its first page
832
+ // has fully finished (the ref may still hold an earlier query's), in which case there's nowhere to
833
+ // continue from yet.
834
+ const searchCursor = decodeCursor(compositeCursorRef.current, fingerprint);
835
+ if (
836
+ loadMoreInFlightRef.current ||
837
+ !hasMore ||
838
+ loading ||
839
+ viewMode !== "date" ||
840
+ !folderUid ||
841
+ (isSearching && !searchCursor)
842
+ ) {
843
+ return;
844
+ }
845
+ // A ref, not `loadingMore` state: the observer and the continuation effect below can both call in the
846
+ // same tick, each closing over a render where `loadingMore` was still false.
847
+ loadMoreInFlightRef.current = true;
848
+ setLoadMoreError(null);
849
+ setLoadMoreStalled(false);
850
+ setLoadingMore(true);
851
+ /** Records whether a landed page added rows, and whether the continuation effect should keep going. */
852
+ const notePageLanded = (addedRows: boolean, moreRemain: boolean) => {
853
+ if (addedRows) {
854
+ emptyPageStreakRef.current = 0;
855
+ setAppendedPageCount((n) => n + 1);
856
+ } else if (moreRemain) {
857
+ emptyPageStreakRef.current += 1;
858
+ if (emptyPageStreakRef.current <= MAX_EMPTY_PAGE_CONTINUATIONS) {
859
+ setAppendedPageCount((n) => n + 1);
860
+ } else {
861
+ emptyPageStreakRef.current = 0;
862
+ setLoadMoreStalled(true);
863
+ }
864
+ }
865
+ };
866
+ // A query/folder/view change while this page is in flight bumps the run id (see the effect above) -
867
+ // its rows then belong to a list that's no longer on screen and must not be appended to the new one.
868
+ const myRunId = searchRunIdRef.current;
869
+ const isCurrentRun = () => searchRunIdRef.current === myRunId;
870
+ try {
871
+ if (isSearching) {
872
+ const unlocked = getUnlockedKeys(mailboxUid!);
873
+ const cursor = searchCursor!;
874
+
875
+ const [tier1Page, tier2Page] = await Promise.all([
876
+ searchMailbox(parsed.text, tier1SearchParams(parsed, cursor.tier1Cursor, mailboxUid!)),
877
+ searchLocalIndex(mailboxUid!, parsed, unlocked, MESSAGE_PAGE_SIZE, cursor.tier2Offset),
878
+ ]);
879
+ // The first page cached this pass under `tier3Key` before it created the cursor.
880
+ const tier3Full = tier3CacheRef.current.get(cursor.tier3Key)!;
881
+ const tier3Offset = cursor.tier3Offset;
882
+ const tier3Page = tier3Full.slice(tier3Offset, tier3Offset + MESSAGE_PAGE_SIZE);
883
+
884
+ // Unlike the fresh-search pass above, a load-more page is resolved and appended in one
885
+ // shot rather than progressively revealed - rows already on screen shouldn't reorder or
886
+ // grow skeletons out from under a reader who has since scrolled past them. Any Tier 1
887
+ // metadataOnly hit this page that neither Tier 2 nor Tier 3 (both already awaited above)
888
+ // confirms simply keeps showing its existing placeholder text rather than a live skeleton
889
+ // - a deliberate, documented scope trim of progressive reveal to the first page only.
890
+ const merged = mergeSearchResults(capSkeletons(tier1Page.results), tier2Page.results, tier3Page);
891
+ const { messages: more, snippets: moreSnippets } = await resolveHitsToMessages(merged, resolvedMessageCacheRef.current);
892
+ if (!isCurrentRun()) {
893
+ return;
894
+ }
895
+ const addedRows = hasUnseenMessages(messagesRef.current, more);
896
+ setMessages((prev) => appendUnseenMessages(prev, more));
897
+ setSnippets((prev) => ({ ...prev, ...moreSnippets }));
898
+
899
+ const nextTier3Offset = tier3Offset + tier3Page.length;
900
+ compositeCursorRef.current = {
901
+ tier1Cursor: tier1Page.nextCursor,
902
+ tier2Offset: cursor.tier2Offset + tier2Page.results.length,
903
+ tier3Offset: nextTier3Offset,
904
+ tier3Key: cursor.tier3Key,
905
+ fingerprint,
906
+ };
907
+ const moreRemain = !!tier1Page.nextCursor || tier2Page.hasMore || nextTier3Offset < tier3Full.length;
908
+ setHasMore(moreRemain);
909
+ notePageLanded(addedRows, moreRemain);
910
+ } else {
911
+ // The page containing the first message not fetched yet. After a local removal that offset is
912
+ // no longer a page boundary, so this page overlaps rows already shown - `appendUnseenMessages()`
913
+ // drops those - rather than skipping the message that shifted back across the boundary.
914
+ const page = Math.floor(listedOffsetRef.current / MESSAGE_PAGE_SIZE);
915
+ const more = await listMessages(folderUid, { page, limit: MESSAGE_PAGE_SIZE });
916
+ if (!isCurrentRun()) {
917
+ return;
918
+ }
919
+ const addedRows = hasUnseenMessages(messagesRef.current, more);
920
+ setMessages((prev) => appendUnseenMessages(prev, more));
921
+ setHasMore(more.length === MESSAGE_PAGE_SIZE);
922
+ listedOffsetRef.current = page * MESSAGE_PAGE_SIZE + more.length;
923
+ notePageLanded(addedRows, more.length === MESSAGE_PAGE_SIZE);
924
+ }
925
+ } catch (err) {
926
+ // Shown next to the sentinel with a Retry button - never auto-retried (see the continuation
927
+ // effect below), so a failing server isn't hammered while the sentinel stays in view.
928
+ if (isCurrentRun()) {
929
+ setLoadMoreError(err instanceof ApiRequestError ? err.message : "Could not load more messages.");
930
+ }
931
+ } finally {
932
+ loadMoreInFlightRef.current = false;
933
+ setLoadingMore(false);
934
+ }
935
+ }, [hasMore, loading, viewMode, folderUid, isSearching, searchQuery, mailboxUid]);
936
+
937
+ // Always calls the latest `loadMore` closure so the effect below doesn't need `loadMore` itself in its
938
+ // dependency array (it changes on every keystroke/page load, which would otherwise mean nothing here).
939
+ const loadMoreRef = useRef(loadMore);
940
+ loadMoreRef.current = loadMore;
941
+
942
+ // Keyed on the sentinel node itself (see `sentinel`'s own comment): it unmounts whenever the list shows
943
+ // "Loading..." and remounts afterwards, so an observer attached once to an earlier node would watch a
944
+ // detached element forever. Only ever rendered in "By date" mode, so no view-mode check is needed.
945
+ useEffect(() => {
946
+ if (!sentinel) {
947
+ return;
948
+ }
949
+ const observer = new IntersectionObserver(
950
+ (entries) => {
951
+ if (entries.some((entry) => entry.isIntersecting) && !loadMoreErrorRef.current) {
952
+ void loadMoreRef.current();
953
+ }
954
+ },
955
+ { root: scrollContainerRef.current, rootMargin: `${LOAD_MORE_ROOT_MARGIN_PX}px` },
956
+ );
957
+ observer.observe(sentinel);
958
+ return () => observer.disconnect();
959
+ }, [sentinel]);
960
+
961
+ // An observer only reports *changes* - a sentinel still in view after a page lands (the new rows didn't
962
+ // push it out of view, e.g. the Focused/Other filter hid every one of them) never reports again, so
963
+ // keep loading while it's still in view. Only after a page that appended rows, or a full page of rows
964
+ // already shown (bounded - see `emptyPageStreakRef`), never after a failure, and only when the sentinel's
965
+ // real geometry says it's still in view (the observer's last report may predate the rows that just landed).
966
+ useEffect(() => {
967
+ if (appendedPageCount === 0) {
968
+ return;
969
+ }
970
+ // The scroll container is always mounted whenever a sentinel is (the sentinel lives inside it).
971
+ if (sentinel && isWithinLoadMoreRange(sentinel, scrollContainerRef.current!)) {
972
+ void loadMoreRef.current();
973
+ }
974
+ }, [appendedPageCount]);
975
+
976
+ function removeListedMessage(uid: string) {
977
+ setMessages((prev) => prev.filter((m) => m.uid !== uid));
978
+ listedOffsetRef.current = Math.max(0, listedOffsetRef.current - 1);
979
+ setSelectedUid(null);
980
+ }
981
+
982
+ const selected = messages.find((m) => m.uid === selectedUid) ?? null;
983
+ const selectedConversation = conversations.find((c) => c.conversationId === selectedConversationId) ?? null;
984
+ const attachments = useMessageAttachments(selected);
985
+ useMarkMessageRead(selected, (updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m))));
986
+ // Search results can span every folder in the mailbox, not just the one selected in the sidebar - a
987
+ // selected message's own folderUid is the only reliable source for its actual folder type once
988
+ // searching (outside search, every message in `messages` already comes from `folderUid` itself, so
989
+ // this falls back to the sidebar selection unchanged). Aggregate views span every *mailbox* too, so
990
+ // the folder list consulted is the selected message's own mailbox's, not the shell's ambient one.
991
+ const spansFolders = isSearching || !!aggregateFolderType;
992
+ const selectedFolderUid = spansFolders ? (selected?.folderUid ?? folderUid) : folderUid;
993
+ const selectedMailboxUid = spansFolders ? (selected?.mailboxUid ?? activeMailboxUid) : mailboxUid;
994
+ const folders = mailboxFolders.find((mf) => mf.mailbox.uid === selectedMailboxUid)?.folders ?? [];
995
+ const isSentItems = folders.find((f) => f.uid === selectedFolderUid)?.type === "sent_items";
996
+ const isOutbox = folders.find((f) => f.uid === selectedFolderUid)?.type === "outbox";
997
+ const isInbox = folders.find((f) => f.uid === selectedFolderUid)?.type === "inbox";
998
+ const draftsFolderUid = folders.find((f) => f.type === "drafts")?.uid;
999
+
1000
+ // Focused/Other is an Inbox-only concept (see `MessageDetailPane`'s own `isInbox` doc comment) — the
1001
+ // sub-tabs only ever render there, so a message with no `inferenceClassification` (the common case:
1002
+ // absent means Focused) or an explicit `"focused"` counts as Focused, everything else as Other. Not
1003
+ // offered for an aggregate view (each mailbox classifies independently; merging that is out of scope).
1004
+ const visibleMessages =
1005
+ !isSearching && !aggregateFolderType && isInbox && classificationFilter !== "all"
1006
+ ? messages.filter((m) =>
1007
+ classificationFilter === "other"
1008
+ ? m.inferenceClassification === "other"
1009
+ : m.inferenceClassification !== "other",
1010
+ )
1011
+ : messages;
1012
+
1013
+ const loadMoreStatus = loadingMore ? (
1014
+ "Loading more…"
1015
+ ) : loadMoreError ? (
1016
+ <span className="inline-flex items-center gap-2">
1017
+ <span role="alert" className="text-danger">
1018
+ {loadMoreError}
1019
+ </span>
1020
+ <button type="button" onClick={() => void loadMoreRef.current()} className="text-primary-dark hover:underline font-medium">
1021
+ Retry
1022
+ </button>
1023
+ </span>
1024
+ ) : loadMoreStalled ? (
1025
+ <button type="button" onClick={() => void loadMoreRef.current()} className="text-primary-dark hover:underline font-medium">
1026
+ Load more
1027
+ </button>
1028
+ ) : null;
1029
+
1030
+ function handleSelect(message: Message) {
1031
+ if (isMobile) {
1032
+ window.location.href = `/messages/${encodeURIComponent(message.uid)}`;
1033
+ return;
1034
+ }
1035
+ setSelectedUid(message.uid);
1036
+ }
1037
+
1038
+ function handleSelectConversation(conversation: ConversationSummary) {
1039
+ if (isMobile) {
1040
+ // No dedicated mobile thread route yet — the existing single-message detail route already
1041
+ // handles any message uid regardless of conversation grouping, so land on the most recent
1042
+ // message in the thread rather than building a second mobile detail page for this phase.
1043
+ // `messageUids` always has at least one entry — a `ConversationSummary` only ever exists
1044
+ // because it was grouped from real messages (see `BaseMessageRoute.conversations()`).
1045
+ const latestUid = conversation.messageUids[conversation.messageUids.length - 1];
1046
+ window.location.href = `/messages/${encodeURIComponent(latestUid)}`;
1047
+ return;
1048
+ }
1049
+ setSelectedConversationId(conversation.conversationId);
1050
+ }
1051
+
1052
+ if (!folderUid && !aggregateFolderType) {
1053
+ // `MailShell` never renders this component at all until a mailbox is resolved (see its own
1054
+ // full-screen `MailboxProvisioning` takeover otherwise) — this is purely the brief gap before
1055
+ // that mailbox's own folder list has finished loading, not a "no mailbox" state. Distinct text
1056
+ // from the message list's own "Loading…" below — otherwise the two transient states become
1057
+ // indistinguishable to anything (a test, a user re-reading the screen) that catches this one.
1058
+ return <p className="p-8 text-sm text-text-muted">Loading your mailbox&hellip;</p>;
1059
+ }
1060
+
1061
+ return (
1062
+ <div className="flex h-full min-h-0">
1063
+ <div ref={scrollContainerRef} className="w-full md:w-96 shrink-0 md:border-r border-border overflow-y-auto">
1064
+ <div className="flex border-b border-border text-sm">
1065
+ <button
1066
+ type="button"
1067
+ onClick={() => setViewMode("date")}
1068
+ className={[
1069
+ "flex-1 py-2 font-semibold",
1070
+ viewMode === "date" ? "text-primary-dark border-b-2 border-primary-dark" : "text-text-muted",
1071
+ ].join(" ")}
1072
+ >
1073
+ By date
1074
+ </button>
1075
+ <button
1076
+ type="button"
1077
+ onClick={() => setViewMode("conversation")}
1078
+ className={[
1079
+ "flex-1 py-2 font-semibold",
1080
+ viewMode === "conversation" ? "text-primary-dark border-b-2 border-primary-dark" : "text-text-muted",
1081
+ ].join(" ")}
1082
+ >
1083
+ By conversation
1084
+ </button>
1085
+ </div>
1086
+ {viewMode === "conversation" && (
1087
+ <p className="p-3 text-xs text-text-muted border-b border-border">
1088
+ Showing every conversation in this mailbox — the selected folder doesn&apos;t filter this view.
1089
+ </p>
1090
+ )}
1091
+ {viewMode === "date" && (
1092
+ <div className="p-2 border-b border-border">
1093
+ <input
1094
+ type="search"
1095
+ value={searchInput}
1096
+ onChange={(e) => setSearchInput(e.target.value)}
1097
+ placeholder={aggregateFolderType ? "Open a mailbox's own folder to search" : "Search all mail…"}
1098
+ aria-label="Search all mail"
1099
+ disabled={!!aggregateFolderType}
1100
+ className="w-full text-sm px-3 py-1.5 rounded-md border border-border bg-surface disabled:opacity-55"
1101
+ />
1102
+ </div>
1103
+ )}
1104
+ {viewMode === "date" && isInbox && !isSearching && !aggregateFolderType && (
1105
+ <div className="flex border-b border-border text-xs">
1106
+ {(["all", "focused", "other"] as const).map((value) => (
1107
+ <button
1108
+ key={value}
1109
+ type="button"
1110
+ onClick={() => setClassificationFilter(value)}
1111
+ className={[
1112
+ "flex-1 py-1.5 font-semibold",
1113
+ classificationFilter === value
1114
+ ? "text-primary-dark border-b-2 border-primary-dark"
1115
+ : "text-text-muted",
1116
+ ].join(" ")}
1117
+ >
1118
+ {value === "all" ? "All" : value === "focused" ? "Focused" : "Other"}
1119
+ </button>
1120
+ ))}
1121
+ </div>
1122
+ )}
1123
+
1124
+ {isSearching && (
1125
+ <div className="px-4 py-1.5 text-xs text-text-muted border-b border-border flex items-center justify-between gap-2">
1126
+ {/* §_Progressive Results_: "Never show a hard count until every tier has reported.
1127
+ Display n of ??, or omit the count. A settled count is the signal that ordering
1128
+ is final." */}
1129
+ <span>
1130
+ {tier1Done && tier2Done && tier3Done
1131
+ ? `${visibleMessages.length} result${visibleMessages.length === 1 ? "" : "s"}`
1132
+ : `${visibleMessages.length} of ??`}
1133
+ </span>
1134
+ {tier2Done && !searchAllMail && (
1135
+ <button
1136
+ type="button"
1137
+ onClick={handleSearchAllMail}
1138
+ className="text-primary-dark hover:underline font-medium shrink-0"
1139
+ >
1140
+ Search all mail
1141
+ </button>
1142
+ )}
1143
+ </div>
1144
+ )}
1145
+ {isSearching && coverage?.indexedFrom && (
1146
+ <div className="px-4 py-1.5 text-xs text-text-muted border-b border-border">
1147
+ Local search covers messages back to {new Date(coverage.indexedFrom).toLocaleDateString()}
1148
+ {coverage.building ? " (still building)" : ""}
1149
+ {searchAllMail
1150
+ ? " - searching everything, not just recent mail."
1151
+ : " - older encrypted mail is still searched, just slower."}
1152
+ </div>
1153
+ )}
1154
+ {isSearching && !getUnlockedKeys(mailboxUid!) && (
1155
+ <div className="px-4 py-2 border-b border-border bg-surface-alt">
1156
+ <button
1157
+ type="button"
1158
+ onClick={handleUnlockSearch}
1159
+ className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
1160
+ >
1161
+ <HiOutlineLockClosed size={12} aria-hidden="true" />
1162
+ Unlock to include encrypted messages in these results
1163
+ </button>
1164
+ </div>
1165
+ )}
1166
+ {!isSearching && viewMode === "date" && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (
1167
+ <div className="px-4 py-2 border-b border-border bg-surface-alt">
1168
+ <button
1169
+ type="button"
1170
+ onClick={handleUnlockList}
1171
+ className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
1172
+ >
1173
+ <HiOutlineLockClosed size={12} aria-hidden="true" />
1174
+ Unlock to show {undecryptedEncryptedUids.length === 1 ? "an encrypted message's" : "encrypted messages'"} subject
1175
+ </button>
1176
+ </div>
1177
+ )}
1178
+
1179
+ {error && (
1180
+ <div className="p-4">
1181
+ <Alert>{error}</Alert>
1182
+ </div>
1183
+ )}
1184
+
1185
+ {loading ? (
1186
+ <p className="p-4 text-sm text-text-muted">Loading&hellip;</p>
1187
+ ) : viewMode === "conversation" ? (
1188
+ <ConversationList
1189
+ conversations={conversations}
1190
+ selectedId={selectedConversationId}
1191
+ onSelect={handleSelectConversation}
1192
+ />
1193
+ ) : visibleMessages.length === 0 ? (
1194
+ <>
1195
+ <p className="p-4 text-sm text-text-muted">
1196
+ {isSearching
1197
+ ? `No messages match "${searchQuery}".`
1198
+ : classificationFilter === "all"
1199
+ ? "No messages in this folder."
1200
+ : "No messages here."}
1201
+ </p>
1202
+ {/* Still offered while a filter hides every loaded row - what it's looking for may be
1203
+ on a later page. */}
1204
+ {hasMore && (
1205
+ <div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
1206
+ {loadMoreStatus}
1207
+ </div>
1208
+ )}
1209
+ </>
1210
+ ) : (
1211
+ <>
1212
+ <ul>
1213
+ {visibleMessages.map((message) => (
1214
+ <li key={message.uid}>
1215
+ <button
1216
+ type="button"
1217
+ onClick={() => handleSelect(message)}
1218
+ className={[
1219
+ "w-full text-left px-4 py-3 border-b border-border",
1220
+ message.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt",
1221
+ message.flags.read ? "" : "font-semibold",
1222
+ ].join(" ")}
1223
+ >
1224
+ <div className="flex items-center justify-between gap-2 text-sm">
1225
+ <span className="truncate">{message.from.displayName || message.from.address}</span>
1226
+ <span className="text-xs text-text-muted shrink-0">
1227
+ {new Date(message.receivedDate).toLocaleDateString()}
1228
+ </span>
1229
+ </div>
1230
+ {aggregateFolderType && (
1231
+ // The one view where a row needs to say which mailbox it came from.
1232
+ <div className="text-xs text-text-muted truncate font-normal">
1233
+ {mailboxes.find((mb) => mb.uid === message.mailboxUid)?.displayName}
1234
+ </div>
1235
+ )}
1236
+ {isSearching && pendingUids.has(message.uid) ? (
1237
+ // §_Progressive Results_: "Unresolved encrypted results MUST
1238
+ // be rendered as skeleton entries in place, not appended on
1239
+ // arrival." This uid is a Tier 1 metadataOnly guess Tier 2/3
1240
+ // haven't confirmed (or ruled out) yet.
1241
+ <div className="flex flex-col gap-1.5 py-0.5">
1242
+ <Skeleton height="h-3.5" className="w-2/3 rounded-sm" />
1243
+ <Skeleton height="h-3" className="w-full rounded-sm" />
1244
+ </div>
1245
+ ) : (
1246
+ <>
1247
+ <div className="text-sm truncate">
1248
+ {decryptedRows[message.uid]?.subject ||
1249
+ (message.subject === ENCRYPTED_SUBJECT_PLACEHOLDER
1250
+ ? "Encrypted message"
1251
+ : message.subject) ||
1252
+ "(no subject)"}
1253
+ </div>
1254
+ <div className="text-xs text-text-muted truncate font-normal">
1255
+ {snippets[message.uid] || decryptedRows[message.uid]?.preview || message.bodyPreview}
1256
+ </div>
1257
+ </>
1258
+ )}
1259
+ </button>
1260
+ </li>
1261
+ ))}
1262
+ </ul>
1263
+ {hasMore && (
1264
+ <div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
1265
+ {loadMoreStatus}
1266
+ </div>
1267
+ )}
1268
+ {aggregateFolderType && (
1269
+ <p className="p-4 text-center text-xs text-text-muted">
1270
+ Showing the most recent mail from each mailbox. Open a specific mailbox&rsquo;s folder to
1271
+ see older mail.
1272
+ </p>
1273
+ )}
1274
+ </>
1275
+ )}
1276
+ </div>
1277
+ <div className="hidden md:flex flex-1 min-w-0">
1278
+ {viewMode === "conversation" ? (
1279
+ <ConversationThreadPane
1280
+ conversation={selectedConversation}
1281
+ folders={mailboxFolders.find((mf) => mf.mailbox.uid === activeMailboxUid)?.folders ?? []}
1282
+ labels={labels}
1283
+ />
1284
+ ) : (
1285
+ <MessageDetailPane
1286
+ message={selected}
1287
+ attachments={attachments}
1288
+ isSentItems={isSentItems}
1289
+ onRecalled={(updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m)))}
1290
+ isOutbox={isOutbox}
1291
+ isInbox={isInbox}
1292
+ onClassified={(updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m)))}
1293
+ onReceiptHandled={(updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m)))}
1294
+ draftsFolderUid={draftsFolderUid}
1295
+ onScheduledSendCanceled={(updated) => {
1296
+ // The message moved out of the currently-viewed Outbox folder (into Drafts)
1297
+ // — unlike a recall, which patches a message in place, this removes it from
1298
+ // the list entirely, matching what a real folder switch would show.
1299
+ removeListedMessage(updated.uid);
1300
+ }}
1301
+ onArchived={(updated) => {
1302
+ // Same reasoning as onScheduledSendCanceled above — the message moved out of
1303
+ // whichever folder is currently being viewed (into Archive), so it's removed
1304
+ // from the list rather than patched in place.
1305
+ removeListedMessage(updated.uid);
1306
+ }}
1307
+ labels={labels}
1308
+ onLabelsChanged={(updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m)))}
1309
+ />
1310
+ )}
1311
+ </div>
1312
+ </div>
1313
+ );
1314
+ }