@rapidmx/web-client 0.30.0 → 0.31.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 (400) hide show
  1. package/apps/admin/audit-log/index.tsx +3 -3
  2. package/apps/admin/branding/index.tsx +3 -3
  3. package/apps/admin/data-requests/index.tsx +18 -7
  4. package/apps/admin/distribution-lists/[uid].tsx +3 -3
  5. package/apps/admin/distribution-lists/index.tsx +3 -3
  6. package/apps/admin/distribution-lists/new/index.tsx +3 -3
  7. package/apps/admin/domains/[uid].tsx +6 -5
  8. package/apps/admin/domains/index.tsx +3 -3
  9. package/apps/admin/domains/new/index.tsx +3 -3
  10. package/apps/admin/escrow-scopes/[uid].tsx +11 -5
  11. package/apps/admin/escrow-scopes/index.tsx +3 -3
  12. package/apps/admin/escrow-scopes/new/index.tsx +9 -3
  13. package/apps/admin/index.tsx +4 -4
  14. package/apps/admin/ingest-queue/index.tsx +3 -3
  15. package/apps/admin/mailboxes/[uid].tsx +15 -7
  16. package/apps/admin/quarantine/index.tsx +4 -4
  17. package/apps/admin/retention-policy/index.tsx +3 -3
  18. package/apps/admin/signing-certificates/index.tsx +3 -3
  19. package/apps/admin/transport-rules/[uid].tsx +4 -4
  20. package/apps/admin/transport-rules/index.tsx +3 -3
  21. package/apps/admin/transport-rules/new/index.tsx +3 -3
  22. package/apps/escrow/matters/[uid].tsx +26 -2
  23. package/apps/escrow/matters/new/index.tsx +4 -0
  24. package/apps/shared/calendar/calendarLiveUpdates.ts +11 -10
  25. package/apps/shared/calendar/useCalendarReminders.ts +2 -0
  26. package/apps/shared/components/admin/ActionAlert.tsx +32 -0
  27. package/apps/shared/components/admin/diagnostics/InformationPanel.tsx +1 -1
  28. package/apps/shared/components/admin/diagnostics/InstalledPlugins.tsx +1 -1
  29. package/apps/shared/components/admin/diagnostics/LogsPanel.tsx +1 -1
  30. package/apps/shared/components/admin/diagnostics/RuntimePanel.tsx +1 -1
  31. package/apps/shared/components/admin/diagnostics/SystemPanel.tsx +1 -1
  32. package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +4 -4
  33. package/apps/shared/components/admin/elevation.ts +19 -0
  34. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +58 -3
  35. package/apps/shared/components/admin/layout/AdminShell.tsx +6 -1
  36. package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +3 -3
  37. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +4 -4
  38. package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +4 -4
  39. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  40. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +6 -6
  41. package/apps/shared/components/admin/settings/BrandingForm.tsx +22 -9
  42. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -4
  43. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +33 -4
  44. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +3 -3
  45. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +8 -3
  46. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +3 -3
  47. package/apps/shared/components/admin/settings/PluginsManager.tsx +39 -17
  48. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +25 -12
  49. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +40 -7
  50. package/apps/shared/components/admin/setup/SetupWizard.tsx +8 -6
  51. package/apps/shared/components/admin/signOut.ts +3 -0
  52. package/apps/shared/components/admin/usePagedList.tsx +2 -2
  53. package/apps/shared/components/calendar/DescriptionEditor.tsx +2 -1
  54. package/apps/shared/components/calendar/EventDetails.tsx +34 -8
  55. package/apps/shared/components/calendar/EventEditor.tsx +90 -40
  56. package/apps/shared/components/calendar/EventFormParts.tsx +5 -2
  57. package/apps/shared/components/calendar/EventMatchNav.tsx +14 -2
  58. package/apps/shared/components/calendar/EventModal.tsx +25 -1
  59. package/apps/shared/components/calendar/FindATime.tsx +10 -2
  60. package/apps/shared/components/calendar/ListView.tsx +99 -25
  61. package/apps/shared/components/calendar/RecurrenceEditor.tsx +19 -8
  62. package/apps/shared/components/calendar/SplitDayView.tsx +14 -18
  63. package/apps/shared/components/calendar/TaskQuickForm.tsx +3 -2
  64. package/apps/shared/components/calendar/TimeGridView.tsx +35 -19
  65. package/apps/shared/components/calendar/gridLayout.ts +94 -0
  66. package/apps/shared/components/calendar/layout/CalendarShell.tsx +1 -1
  67. package/apps/shared/components/calendar/timePicker.ts +5 -2
  68. package/apps/shared/components/contacts/ContactCard.tsx +15 -4
  69. package/apps/shared/components/contacts/ContactDetailPane.tsx +54 -15
  70. package/apps/shared/components/contacts/ContactForm.tsx +61 -17
  71. package/apps/shared/components/contacts/ContactPhotoEditor.tsx +88 -0
  72. package/apps/shared/components/contacts/ContactPhotoField.tsx +36 -39
  73. package/apps/shared/components/contacts/layout/ContactsShell.tsx +1 -1
  74. package/apps/shared/components/contacts/participantDetails.ts +18 -1
  75. package/apps/shared/components/layout/AppShell.tsx +9 -0
  76. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +9 -4
  77. package/apps/shared/components/layout/MailboxProvisioning.tsx +31 -3
  78. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +4 -2
  79. package/apps/shared/components/mail/ConversationList.tsx +49 -6
  80. package/apps/shared/components/mail/ConversationThreadPane.tsx +1 -1
  81. package/apps/shared/components/mail/MenuButton.tsx +3 -400
  82. package/apps/shared/components/mail/MessageDetailPane.tsx +16 -13
  83. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +11 -1
  84. package/apps/shared/components/mail/compose/ComposeWindow.tsx +57 -11
  85. package/apps/shared/components/mail/compose/GifPicker.tsx +10 -4
  86. package/apps/shared/components/mail/compose/RecipientInput.tsx +9 -7
  87. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +14 -1
  88. package/apps/shared/components/mail/compose/encryptionRequirement.ts +5 -0
  89. package/apps/shared/components/mail/compose/quotedBody.ts +31 -6
  90. package/apps/shared/components/mail/layout/MailShell.tsx +1 -1
  91. package/apps/shared/components/mail/reading/MessageBody.tsx +13 -7
  92. package/apps/shared/components/mail/reading/bodyContent.ts +2 -0
  93. package/apps/shared/components/mail/reading/bodyHtml.ts +25 -2
  94. package/apps/shared/components/mail/writableMailboxes.ts +2 -0
  95. package/apps/shared/components/rules/RuleBuilder.tsx +11 -2
  96. package/apps/shared/components/settings/GravatarSetting.tsx +27 -0
  97. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -1
  98. package/apps/shared/components/tasks/dueDate.ts +29 -0
  99. package/apps/shared/components/tasks/layout/TasksShell.tsx +1 -1
  100. package/apps/shared/mail/decryptedMessages.ts +51 -2
  101. package/apps/shared/mail/listSnapshots.ts +2 -0
  102. package/apps/shared/mail/outbox/sendDecision.ts +14 -3
  103. package/apps/shared/mail/outbox/sendJob.ts +5 -0
  104. package/apps/shared/mail/useMailLiveUpdates.ts +7 -1
  105. package/apps/shared/mail/usePermanentDelete.tsx +3 -1
  106. package/apps/shared/notifications/apiErrors.ts +2 -0
  107. package/apps/shared/notifications/preferences.ts +1 -1
  108. package/apps/shared/notifications/store.ts +3 -1
  109. package/apps/shared/plugins/pluginNav.ts +4 -2
  110. package/apps/www/calendar/index.tsx +24 -12
  111. package/apps/www/contacts/[uid].tsx +6 -1
  112. package/apps/www/contacts/index.tsx +66 -12
  113. package/apps/www/index.tsx +91 -28
  114. package/apps/www/messages/[uid].tsx +8 -3
  115. package/apps/www/settings/auto-reply/index.tsx +13 -0
  116. package/apps/www/settings/blocked-senders/index.tsx +4 -0
  117. package/apps/www/settings/encryption/index.tsx +66 -4
  118. package/apps/www/settings/privacy/index.tsx +4 -1
  119. package/apps/www/settings/profile/index.tsx +12 -1
  120. package/apps/www/settings/sharing/index.tsx +11 -1
  121. package/apps/www/settings/signatures/[uid].tsx +6 -4
  122. package/apps/www/tasks/index.tsx +97 -29
  123. package/dist/apps/admin/audit-log/index.js +3 -3
  124. package/dist/apps/admin/branding/index.js +3 -3
  125. package/dist/apps/admin/data-requests/index.js +17 -7
  126. package/dist/apps/admin/distribution-lists/[uid].js +3 -3
  127. package/dist/apps/admin/distribution-lists/index.js +3 -3
  128. package/dist/apps/admin/distribution-lists/new/index.js +3 -3
  129. package/dist/apps/admin/domains/[uid].js +6 -5
  130. package/dist/apps/admin/domains/index.js +3 -3
  131. package/dist/apps/admin/domains/new/index.js +3 -3
  132. package/dist/apps/admin/escrow-scopes/[uid].js +11 -6
  133. package/dist/apps/admin/escrow-scopes/index.js +3 -3
  134. package/dist/apps/admin/escrow-scopes/new/index.js +9 -4
  135. package/dist/apps/admin/index.js +4 -4
  136. package/dist/apps/admin/ingest-queue/index.js +3 -3
  137. package/dist/apps/admin/mailboxes/[uid].js +15 -7
  138. package/dist/apps/admin/quarantine/index.js +4 -4
  139. package/dist/apps/admin/retention-policy/index.js +3 -3
  140. package/dist/apps/admin/signing-certificates/index.js +3 -3
  141. package/dist/apps/admin/transport-rules/[uid].js +4 -4
  142. package/dist/apps/admin/transport-rules/index.js +3 -3
  143. package/dist/apps/admin/transport-rules/new/index.js +3 -3
  144. package/dist/apps/escrow/matters/[uid].js +8 -3
  145. package/dist/apps/escrow/matters/new/index.js +4 -0
  146. package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +11 -7
  147. package/dist/apps/shared/calendar/calendarLiveUpdates.js +10 -9
  148. package/dist/apps/shared/calendar/useCalendarReminders.js +2 -0
  149. package/dist/apps/shared/components/admin/ActionAlert.d.ts +13 -0
  150. package/dist/apps/shared/components/admin/ActionAlert.js +24 -0
  151. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +1 -1
  152. package/dist/apps/shared/components/admin/diagnostics/InstalledPlugins.js +1 -1
  153. package/dist/apps/shared/components/admin/diagnostics/LogsPanel.js +1 -1
  154. package/dist/apps/shared/components/admin/diagnostics/RuntimePanel.js +1 -1
  155. package/dist/apps/shared/components/admin/diagnostics/SystemPanel.js +1 -1
  156. package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +4 -4
  157. package/dist/apps/shared/components/admin/elevation.d.ts +9 -0
  158. package/dist/apps/shared/components/admin/elevation.js +16 -0
  159. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.d.ts +2 -0
  160. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +41 -1
  161. package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -1
  162. package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.js +3 -3
  163. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +4 -4
  164. package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.js +4 -4
  165. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  166. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +6 -6
  167. package/dist/apps/shared/components/admin/settings/BrandingForm.js +17 -7
  168. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +4 -4
  169. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +17 -5
  170. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +3 -3
  171. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +8 -3
  172. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +3 -3
  173. package/dist/apps/shared/components/admin/settings/PluginsManager.js +26 -12
  174. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +17 -10
  175. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +18 -8
  176. package/dist/apps/shared/components/admin/setup/SetupWizard.js +8 -6
  177. package/dist/apps/shared/components/admin/signOut.js +3 -0
  178. package/dist/apps/shared/components/admin/usePagedList.js +2 -2
  179. package/dist/apps/shared/components/calendar/DescriptionEditor.js +2 -1
  180. package/dist/apps/shared/components/calendar/EventDetails.d.ts +3 -1
  181. package/dist/apps/shared/components/calendar/EventDetails.js +7 -4
  182. package/dist/apps/shared/components/calendar/EventEditor.js +81 -33
  183. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +3 -1
  184. package/dist/apps/shared/components/calendar/EventFormParts.js +3 -3
  185. package/dist/apps/shared/components/calendar/EventMatchNav.d.ts +1 -1
  186. package/dist/apps/shared/components/calendar/EventMatchNav.js +12 -2
  187. package/dist/apps/shared/components/calendar/EventModal.js +10 -2
  188. package/dist/apps/shared/components/calendar/FindATime.js +4 -3
  189. package/dist/apps/shared/components/calendar/ListView.d.ts +10 -4
  190. package/dist/apps/shared/components/calendar/ListView.js +92 -27
  191. package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +4 -1
  192. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +15 -10
  193. package/dist/apps/shared/components/calendar/SplitDayView.js +11 -17
  194. package/dist/apps/shared/components/calendar/TaskQuickForm.js +3 -2
  195. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -21
  196. package/dist/apps/shared/components/calendar/gridLayout.d.ts +41 -0
  197. package/dist/apps/shared/components/calendar/gridLayout.js +83 -0
  198. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +1 -1
  199. package/dist/apps/shared/components/calendar/timePicker.js +5 -2
  200. package/dist/apps/shared/components/contacts/ContactCard.js +9 -3
  201. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +5 -3
  202. package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -5
  203. package/dist/apps/shared/components/contacts/ContactForm.js +54 -16
  204. package/dist/apps/shared/components/contacts/ContactPhotoEditor.d.ts +21 -0
  205. package/dist/apps/shared/components/contacts/ContactPhotoEditor.js +56 -0
  206. package/dist/apps/shared/components/contacts/ContactPhotoField.d.ts +7 -4
  207. package/dist/apps/shared/components/contacts/ContactPhotoField.js +19 -21
  208. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +1 -1
  209. package/dist/apps/shared/components/contacts/participantDetails.d.ts +4 -1
  210. package/dist/apps/shared/components/contacts/participantDetails.js +17 -1
  211. package/dist/apps/shared/components/layout/AppShell.js +9 -0
  212. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +2 -3
  213. package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +4 -1
  214. package/dist/apps/shared/components/layout/MailboxProvisioning.js +24 -2
  215. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.d.ts +4 -2
  216. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +4 -2
  217. package/dist/apps/shared/components/mail/ConversationList.js +49 -6
  218. package/dist/apps/shared/components/mail/ConversationThreadPane.js +1 -1
  219. package/dist/apps/shared/components/mail/MenuButton.d.ts +2 -78
  220. package/dist/apps/shared/components/mail/MenuButton.js +2 -211
  221. package/dist/apps/shared/components/mail/MessageDetailPane.js +10 -6
  222. package/dist/apps/shared/components/mail/compose/ComposeToolbar.d.ts +3 -0
  223. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +10 -1
  224. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +55 -10
  225. package/dist/apps/shared/components/mail/compose/GifPicker.js +10 -4
  226. package/dist/apps/shared/components/mail/compose/RecipientInput.js +9 -7
  227. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.d.ts +7 -1
  228. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +13 -1
  229. package/dist/apps/shared/components/mail/compose/encryptionRequirement.d.ts +5 -0
  230. package/dist/apps/shared/components/mail/compose/quotedBody.js +28 -5
  231. package/dist/apps/shared/components/mail/layout/MailShell.js +1 -1
  232. package/dist/apps/shared/components/mail/reading/MessageBody.js +11 -6
  233. package/dist/apps/shared/components/mail/reading/bodyContent.js +2 -0
  234. package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +5 -1
  235. package/dist/apps/shared/components/mail/reading/bodyHtml.js +23 -2
  236. package/dist/apps/shared/components/mail/writableMailboxes.js +2 -0
  237. package/dist/apps/shared/components/rules/RuleBuilder.js +9 -1
  238. package/dist/apps/shared/components/settings/GravatarSetting.d.ts +6 -0
  239. package/dist/apps/shared/components/settings/GravatarSetting.js +15 -0
  240. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -1
  241. package/dist/apps/shared/components/tasks/dueDate.d.ts +12 -0
  242. package/dist/apps/shared/components/tasks/dueDate.js +26 -0
  243. package/dist/apps/shared/components/tasks/layout/TasksShell.js +1 -1
  244. package/dist/apps/shared/mail/decryptedMessages.d.ts +8 -0
  245. package/dist/apps/shared/mail/decryptedMessages.js +46 -2
  246. package/dist/apps/shared/mail/listSnapshots.js +2 -0
  247. package/dist/apps/shared/mail/outbox/sendDecision.d.ts +6 -3
  248. package/dist/apps/shared/mail/outbox/sendDecision.js +13 -3
  249. package/dist/apps/shared/mail/outbox/sendJob.d.ts +1 -0
  250. package/dist/apps/shared/mail/outbox/sendJob.js +5 -1
  251. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +3 -0
  252. package/dist/apps/shared/mail/useMailLiveUpdates.js +6 -1
  253. package/dist/apps/shared/mail/usePermanentDelete.d.ts +2 -0
  254. package/dist/apps/shared/mail/usePermanentDelete.js +1 -1
  255. package/dist/apps/shared/notifications/apiErrors.js +2 -0
  256. package/dist/apps/shared/notifications/preferences.d.ts +1 -1
  257. package/dist/apps/shared/notifications/preferences.js +1 -1
  258. package/dist/apps/shared/notifications/store.js +3 -1
  259. package/dist/apps/shared/plugins/pluginNav.d.ts +2 -1
  260. package/dist/apps/shared/plugins/pluginNav.js +4 -2
  261. package/dist/apps/www/calendar/index.js +24 -12
  262. package/dist/apps/www/contacts/[uid].js +6 -2
  263. package/dist/apps/www/contacts/index.js +62 -11
  264. package/dist/apps/www/index.js +85 -30
  265. package/dist/apps/www/messages/[uid].js +8 -3
  266. package/dist/apps/www/settings/auto-reply/index.js +13 -1
  267. package/dist/apps/www/settings/blocked-senders/index.js +4 -0
  268. package/dist/apps/www/settings/encryption/index.js +29 -5
  269. package/dist/apps/www/settings/privacy/index.js +3 -2
  270. package/dist/apps/www/settings/profile/index.js +4 -1
  271. package/dist/apps/www/settings/sharing/index.js +10 -1
  272. package/dist/apps/www/settings/signatures/[uid].js +6 -4
  273. package/dist/apps/www/tasks/index.js +94 -32
  274. package/dist/lib/admin/auditLogApi.js +2 -2
  275. package/dist/lib/admin/distributionListsApi.js +2 -2
  276. package/dist/lib/admin/domainsApi.js +2 -2
  277. package/dist/lib/admin/escrowAuditLogApi.js +2 -2
  278. package/dist/lib/admin/escrowScopesApi.js +2 -2
  279. package/dist/lib/admin/pluginsApi.d.ts +13 -2
  280. package/dist/lib/admin/pluginsApi.js +8 -0
  281. package/dist/lib/admin/retentionPolicyApi.d.ts +3 -0
  282. package/dist/lib/admin/retentionPolicyApi.js +3 -0
  283. package/dist/lib/admin/transportRulesApi.js +2 -2
  284. package/dist/lib/appearance/preferencesApi.d.ts +1 -1
  285. package/dist/lib/appearance/preferencesApi.js +3 -14
  286. package/dist/lib/auth/session.d.ts +51 -12
  287. package/dist/lib/auth/session.js +214 -38
  288. package/dist/lib/branding/brandingApi.d.ts +3 -3
  289. package/dist/lib/branding/brandingApi.js +9 -21
  290. package/dist/lib/calendar/calendarApi.js +3 -1
  291. package/dist/lib/calendar/eventSearch.js +43 -6
  292. package/dist/lib/calendar/freeBusyApi.d.ts +2 -1
  293. package/dist/lib/calendar/freeBusyApi.js +13 -5
  294. package/dist/lib/calendar/recurrence.js +51 -3
  295. package/dist/lib/calendar/useDayKey.d.ts +7 -0
  296. package/dist/lib/calendar/useDayKey.js +43 -0
  297. package/dist/lib/components/avatar/ContactAvatar.d.ts +6 -2
  298. package/dist/lib/components/avatar/ContactAvatar.js +37 -8
  299. package/dist/lib/components/avatar/ContactPhotoAvatar.d.ts +11 -0
  300. package/dist/lib/components/avatar/ContactPhotoAvatar.js +18 -0
  301. package/dist/lib/components/forms/CameraCaptureModal.d.ts +17 -0
  302. package/dist/lib/components/forms/CameraCaptureModal.js +88 -0
  303. package/dist/lib/components/forms/ImageEditBadge.d.ts +28 -0
  304. package/dist/lib/components/forms/ImageEditBadge.js +62 -0
  305. package/dist/lib/components/menus/MenuButton.d.ts +78 -0
  306. package/dist/lib/components/menus/MenuButton.js +219 -0
  307. package/dist/lib/components/navigation/BottomTabBar.d.ts +3 -1
  308. package/dist/lib/components/navigation/BottomTabBar.js +22 -3
  309. package/dist/lib/components/overlays/PopoverPortal.d.ts +2 -1
  310. package/dist/lib/components/overlays/PopoverPortal.js +3 -2
  311. package/dist/lib/components/pickers/TimeZonePicker.js +13 -1
  312. package/dist/lib/contacts/contactsApi.d.ts +5 -2
  313. package/dist/lib/contacts/contactsApi.js +16 -19
  314. package/dist/lib/contacts/gravatar.d.ts +14 -3
  315. package/dist/lib/contacts/gravatar.js +42 -5
  316. package/dist/lib/contacts/preparePhoto.d.ts +24 -0
  317. package/dist/lib/contacts/preparePhoto.js +250 -0
  318. package/dist/lib/contacts/useContactPhotoSrc.d.ts +19 -0
  319. package/dist/lib/contacts/useContactPhotoSrc.js +153 -0
  320. package/dist/lib/contacts/vcard.d.ts +3 -0
  321. package/dist/lib/contacts/vcard.js +59 -7
  322. package/dist/lib/crypto/escrowKeys.d.ts +8 -0
  323. package/dist/lib/crypto/escrowKeys.js +20 -1
  324. package/dist/lib/crypto/messageSecurity.d.ts +7 -4
  325. package/dist/lib/crypto/messageSecurity.js +23 -5
  326. package/dist/lib/crypto/passwordUnlock.d.ts +6 -1
  327. package/dist/lib/crypto/passwordUnlock.js +13 -2
  328. package/dist/lib/crypto/smime.js +12 -2
  329. package/dist/lib/mail/compose/composeQuoting.js +3 -2
  330. package/dist/lib/mail/compose/quotedImages.d.ts +6 -3
  331. package/dist/lib/mail/compose/quotedImages.js +35 -9
  332. package/dist/lib/mail/labelsApi.js +2 -2
  333. package/dist/lib/mail/mailApi.d.ts +8 -5
  334. package/dist/lib/mail/mailApi.js +73 -39
  335. package/dist/lib/mail/mailDetailHooks.js +11 -3
  336. package/dist/lib/mail/mailFilterRulesApi.js +2 -2
  337. package/dist/lib/mail/mailSignaturesApi.js +2 -2
  338. package/dist/lib/mail/mailboxImportApi.d.ts +1 -1
  339. package/dist/lib/mail/mailboxImportApi.js +3 -15
  340. package/dist/lib/mail/messageBodySanitizer.d.ts +6 -0
  341. package/dist/lib/mail/messageBodySanitizer.js +44 -1
  342. package/dist/lib/search/queryGrammar.js +12 -4
  343. package/dist/lib/search/searchTier3.d.ts +6 -1
  344. package/dist/lib/search/searchTier3.js +54 -25
  345. package/dist/lib/tasks/tasksApi.js +3 -3
  346. package/dist/lib/util/api.d.ts +26 -4
  347. package/dist/lib/util/api.js +73 -16
  348. package/dist/lib/util/apiQuery.d.ts +11 -0
  349. package/dist/lib/util/apiQuery.js +11 -0
  350. package/dist/lib/util/settleWithLimit.d.ts +5 -0
  351. package/dist/lib/util/settleWithLimit.js +25 -0
  352. package/lib/admin/auditLogApi.ts +2 -2
  353. package/lib/admin/distributionListsApi.ts +2 -2
  354. package/lib/admin/domainsApi.ts +2 -2
  355. package/lib/admin/escrowAuditLogApi.ts +2 -2
  356. package/lib/admin/escrowScopesApi.ts +2 -2
  357. package/lib/admin/pluginsApi.ts +21 -2
  358. package/lib/admin/retentionPolicyApi.ts +4 -0
  359. package/lib/admin/transportRulesApi.ts +2 -2
  360. package/lib/appearance/preferencesApi.ts +3 -14
  361. package/lib/auth/session.ts +239 -39
  362. package/lib/branding/brandingApi.ts +9 -21
  363. package/lib/calendar/calendarApi.ts +3 -1
  364. package/lib/calendar/eventSearch.ts +46 -6
  365. package/lib/calendar/freeBusyApi.ts +14 -6
  366. package/lib/calendar/recurrence.ts +59 -8
  367. package/lib/calendar/useDayKey.ts +45 -0
  368. package/lib/components/avatar/ContactAvatar.tsx +46 -8
  369. package/lib/components/avatar/ContactPhotoAvatar.tsx +26 -0
  370. package/lib/components/forms/CameraCaptureModal.tsx +133 -0
  371. package/lib/components/forms/ImageEditBadge.tsx +121 -0
  372. package/lib/components/menus/MenuButton.tsx +408 -0
  373. package/lib/components/navigation/BottomTabBar.tsx +30 -8
  374. package/lib/components/overlays/PopoverPortal.tsx +3 -2
  375. package/lib/components/pickers/TimeZonePicker.tsx +17 -1
  376. package/lib/contacts/contactsApi.ts +18 -19
  377. package/lib/contacts/gravatar.ts +47 -5
  378. package/lib/contacts/preparePhoto.ts +273 -0
  379. package/lib/contacts/useContactPhotoSrc.ts +177 -0
  380. package/lib/contacts/vcard.ts +60 -7
  381. package/lib/crypto/escrowKeys.ts +21 -1
  382. package/lib/crypto/messageSecurity.ts +33 -9
  383. package/lib/crypto/passwordUnlock.ts +14 -2
  384. package/lib/crypto/smime.ts +13 -2
  385. package/lib/mail/compose/composeQuoting.ts +3 -2
  386. package/lib/mail/compose/quotedImages.ts +39 -9
  387. package/lib/mail/labelsApi.ts +2 -2
  388. package/lib/mail/mailApi.ts +96 -39
  389. package/lib/mail/mailDetailHooks.ts +11 -3
  390. package/lib/mail/mailFilterRulesApi.ts +2 -2
  391. package/lib/mail/mailSignaturesApi.ts +2 -2
  392. package/lib/mail/mailboxImportApi.ts +9 -15
  393. package/lib/mail/messageBodySanitizer.ts +47 -1
  394. package/lib/search/queryGrammar.ts +13 -4
  395. package/lib/search/searchTier3.ts +65 -32
  396. package/lib/tasks/tasksApi.ts +3 -3
  397. package/lib/util/api.ts +99 -16
  398. package/lib/util/apiQuery.ts +13 -0
  399. package/lib/util/settleWithLimit.ts +25 -0
  400. package/package.json +1 -1
@@ -0,0 +1,408 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { ReactNode, useEffect, useRef, useState } from "react";
6
+ import { HiCheck, HiChevronDown, HiChevronLeft, HiChevronRight, HiMinus } from "react-icons/hi2";
7
+ import PopoverPortal from "../overlays/PopoverPortal.js";
8
+
9
+ /** What every row has. `checked` turns it into a radio/checkbox item (the caller says which via `role`) and
10
+ * draws the checkmark column; without it the row is a plain command. */
11
+ interface MenuItemBase {
12
+ key: string;
13
+ label: string;
14
+ /** A one-line explanation under the label, for an item whose effect isn't obvious from its name. */
15
+ description?: string;
16
+ role?: "menuitem" | "menuitemradio" | "menuitemcheckbox";
17
+ /** `"mixed"` is ARIA's third checkbox state - some of the things this row acts on have it and some
18
+ * don't, which is what a label applied to only part of a multi-message selection looks like. */
19
+ checked?: boolean | "mixed";
20
+ /** A colour swatch before the label - a label's own colour. */
21
+ swatchColor?: string;
22
+ /** An icon before the label, for a menu whose rows are commands (the reading pane's "More actions"). */
23
+ icon?: ReactNode;
24
+ /** A tooltip for the row - the full text of a label the row truncates. */
25
+ title?: string;
26
+ disabled?: boolean;
27
+ /** Leaves the menu open after choosing this row, for a multi-select list where several rows are ticked
28
+ * before one command commits them all. */
29
+ keepOpen?: boolean;
30
+ }
31
+
32
+ /** A row that does something when chosen. */
33
+ export interface MenuCommandSpec extends MenuItemBase {
34
+ onSelect: () => void;
35
+ submenu?: never;
36
+ }
37
+
38
+ /** A row that opens a submenu instead: choosing it replaces the menu's contents with these sections, under
39
+ * a Back row. One level deep, which is all any menu here needs - so it has nothing of its own to do. */
40
+ export interface MenuSubmenuSpec extends MenuItemBase {
41
+ submenu: MenuSectionSpec[];
42
+ onSelect?: never;
43
+ }
44
+
45
+ export type MenuItemSpec = MenuCommandSpec | MenuSubmenuSpec;
46
+
47
+ /** A labelled group of items. Groups after the first are drawn with a separator above them. */
48
+ export interface MenuSectionSpec {
49
+ key: string;
50
+ label?: string;
51
+ /** A short note under the group's items - e.g. why some of them are unavailable right now. */
52
+ note?: string;
53
+ items: MenuItemSpec[];
54
+ }
55
+
56
+ // `PopoverPortal` positions a fixed-size box (it has no auto-height mode), so the menu's height is
57
+ // computed from its own contents rather than measured - from whichever level is *shown*, so a submenu
58
+ // isn't left standing in the parent menu's taller box. These are the exact heights the classes below
59
+ // render at, so the box is never short enough to clip its last row: an item is `h-9`, a group label
60
+ // `h-6`, a note as many `leading-4` lines as it wraps to, a separator a 1px rule inside `my-1`, and the
61
+ // list itself `py-1`.
62
+ const ITEM_HEIGHT = 36;
63
+ const ITEM_DESCRIPTION_HEIGHT = 16;
64
+ const GROUP_LABEL_HEIGHT = 24;
65
+ const NOTE_LINE_HEIGHT = 16;
66
+ const NOTE_PADDING = 4;
67
+ /** A note wraps, so its height depends on how much of it fits a line: `text-xs` averages a little over 6px
68
+ * a character, and the note sits inside the list's `px-3`. Rounded so the estimate is never *under* the
69
+ * lines the browser actually draws - a box a few pixels too tall shows blank space, one too short clips. */
70
+ const NOTE_CHAR_WIDTH = 6.4;
71
+ const NOTE_PADDING_X = 24;
72
+ const SEPARATOR_HEIGHT = 9;
73
+ const LIST_PADDING = 8;
74
+ const MENU_MAX_HEIGHT = 460;
75
+ const DEFAULT_MENU_WIDTH = 248;
76
+
77
+ /** How tall `note` renders at `width`, wrapped. */
78
+ function noteHeight(note: string, width: number): number {
79
+ const charsPerLine = Math.max(1, Math.floor((width - NOTE_PADDING_X) / NOTE_CHAR_WIDTH));
80
+ return Math.ceil(note.length / charsPerLine) * NOTE_LINE_HEIGHT + NOTE_PADDING;
81
+ }
82
+
83
+ /** The height `PopoverPortal` is asked for - exact for a short menu, capped (the list scrolls) for a long one. */
84
+ export function menuHeight(sections: MenuSectionSpec[], width: number = DEFAULT_MENU_WIDTH): number {
85
+ let height = LIST_PADDING;
86
+ sections.forEach((section, index) => {
87
+ if (index > 0) {
88
+ height += SEPARATOR_HEIGHT;
89
+ }
90
+ if (section.label) {
91
+ height += GROUP_LABEL_HEIGHT;
92
+ }
93
+ for (const item of section.items) {
94
+ height += ITEM_HEIGHT + (item.description ? ITEM_DESCRIPTION_HEIGHT : 0);
95
+ }
96
+ if (section.note) {
97
+ height += noteHeight(section.note, width);
98
+ }
99
+ });
100
+ return Math.min(height, MENU_MAX_HEIGHT);
101
+ }
102
+
103
+ export interface MenuButtonProps {
104
+ /** The trigger's visible label. Not drawn (nor is the chevron) with `iconOnly`, where `aria-label` is all the trigger says. */
105
+ label: ReactNode;
106
+ /** The trigger is just its `icon` - a round icon button like the reading pane's own command row, whose `className` it takes over
107
+ * completely. `aria-label` (and `title`) name it. */
108
+ iconOnly?: boolean;
109
+ /** The trigger's accessible name, which also names the menu itself - includes the current selection
110
+ * where there is one ("Filter: Unread"), so the button says what it's set to, not just what it does. */
111
+ "aria-label": string;
112
+ icon?: ReactNode;
113
+ disabled?: boolean;
114
+ title?: string;
115
+ sections: MenuSectionSpec[];
116
+ width?: number;
117
+ /** Extra classes for the trigger, on top of the shared toolbar-button styling. */
118
+ className?: string;
119
+ /** Told whenever the menu opens or closes - how a caller resets a draft it keeps for the menu's own
120
+ * multi-select rows (see `useLabelDraft()`). */
121
+ onOpenChange?: (open: boolean) => void;
122
+ }
123
+
124
+ /**
125
+ * A toolbar button that opens an ARIA menu: `role="menu"` rows with checkmarks for the current choice,
126
+ * full keyboard support (Enter/Space or Arrow Down to open, arrows/Home/End to move with wrap, Enter/Space
127
+ * to choose, Escape or Tab to close with focus returning to the trigger) and a click outside to dismiss.
128
+ *
129
+ * Rendered through `PopoverPortal` rather than as an absolutely-positioned child: the mail list toolbar
130
+ * lives inside the list's own `overflow-y-auto` scroll container, which clips an absolute popup however
131
+ * high its `z-index` - the same reason Compose's own pickers portal out (see `PopoverPortal`'s own doc
132
+ * comment). The portal supplies the positioning, the outside-click dismissal and the box; the menu
133
+ * semantics, roving focus and keyboard handling are this component's own.
134
+ */
135
+ export default function MenuButton({
136
+ label,
137
+ iconOnly = false,
138
+ icon,
139
+ disabled,
140
+ title,
141
+ sections,
142
+ width = DEFAULT_MENU_WIDTH,
143
+ className = "",
144
+ "aria-label": ariaLabel,
145
+ onOpenChange,
146
+ }: MenuButtonProps) {
147
+ const [open, setOpen] = useState(false);
148
+ const [activeIndex, setActiveIndex] = useState(0);
149
+ // The key of the item whose submenu is showing, or `null` at the top level. Held as a key, not as the
150
+ // item itself, because `sections` is rebuilt on every render.
151
+ const [submenuKey, setSubmenuKey] = useState<string | null>(null);
152
+ const triggerRef = useRef<HTMLButtonElement>(null);
153
+ const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
154
+ // State, not a ref: `PopoverPortal` renders nothing at all until its own positioning effect has run, so
155
+ // the rows don't exist yet when this component's effects first fire. Keying the focus effect on the
156
+ // menu node itself makes it run again once they do.
157
+ const [menuNode, setMenuNode] = useState<HTMLDivElement | null>(null);
158
+
159
+ const openSubmenu = sections
160
+ .flatMap((section) => section.items)
161
+ .find((item): item is MenuSubmenuSpec => item.key === submenuKey && !!item.submenu);
162
+ /** The row that leaves a submenu again - synthesized rather than asked of the caller, so every submenu
163
+ * has the same way back however it was built. */
164
+ const backItem: MenuCommandSpec = {
165
+ key: "__back",
166
+ label: `Back to ${ariaLabel}`,
167
+ keepOpen: true,
168
+ onSelect: () => leaveSubmenu(),
169
+ };
170
+ const shownSections: MenuSectionSpec[] = openSubmenu
171
+ ? [{ key: "__back", items: [backItem] }, ...openSubmenu.submenu]
172
+ : sections;
173
+ const items = shownSections.flatMap((section) => section.items);
174
+ const enabledIndexes = items.map((item, index) => (item.disabled ? -1 : index)).filter((index) => index !== -1);
175
+
176
+ /** The row focus starts on: the current choice where there is one, else the first row that can take focus. */
177
+ function initialActiveIndex(within: MenuItemSpec[]): number {
178
+ const checked = within.findIndex((item) => item.checked === true && !item.disabled);
179
+ const enabled = within.map((item, index) => (item.disabled ? -1 : index)).filter((index) => index !== -1);
180
+ return checked === -1 ? (enabled[0] ?? 0) : checked;
181
+ }
182
+
183
+ function openMenu() {
184
+ setSubmenuKey(null);
185
+ setActiveIndex(initialActiveIndex(sections.flatMap((section) => section.items)));
186
+ setOpen(true);
187
+ onOpenChange?.(true);
188
+ }
189
+
190
+ function closeMenu(returnFocus: boolean) {
191
+ setOpen(false);
192
+ setSubmenuKey(null);
193
+ onOpenChange?.(false);
194
+ if (returnFocus) {
195
+ triggerRef.current?.focus();
196
+ }
197
+ }
198
+
199
+ function enterSubmenu(item: MenuSubmenuSpec) {
200
+ setSubmenuKey(item.key);
201
+ const rows = item.submenu.flatMap((section) => section.items);
202
+ // Past the Back row, onto the first row of the submenu itself - or, when none of them can take focus (every row is unavailable right now), onto
203
+ // the Back row, so the keyboard still has a place to be.
204
+ setActiveIndex(rows.some((row) => !row.disabled) ? 1 + initialActiveIndex(rows) : 0);
205
+ }
206
+
207
+ function leaveSubmenu() {
208
+ // Back on the row the submenu was opened from.
209
+ const parentIndex = sections.flatMap((section) => section.items).findIndex((item) => item.key === submenuKey);
210
+ setSubmenuKey(null);
211
+ setActiveIndex(parentIndex);
212
+ }
213
+
214
+ /** What a row does when it is chosen: open its submenu, or run it and close unless it asked to stay. */
215
+ function selectItem(item: MenuItemSpec) {
216
+ if (item.submenu) {
217
+ enterSubmenu(item);
218
+ return;
219
+ }
220
+ item.onSelect();
221
+ if (!item.keepOpen) {
222
+ closeMenu(true);
223
+ }
224
+ }
225
+
226
+ // Roving focus: only the active row is tabbable, and it takes DOM focus whenever it changes, so the
227
+ // arrow keys move the screen reader's cursor too rather than only a visual highlight.
228
+ useEffect(() => {
229
+ if (open && menuNode) {
230
+ // A disabled row can't take focus, so a menu with nothing enabled focuses its own container -
231
+ // otherwise the keys it handles would go to whatever had focus before it opened.
232
+ if (enabledIndexes.length === 0) {
233
+ menuNode.focus();
234
+ } else {
235
+ itemRefs.current[activeIndex]?.focus();
236
+ }
237
+ }
238
+ // `submenuKey` too: drilling in or out can land on the same index in the other level's list, and
239
+ // the row that index *means* is a different button, which the effect must move focus to.
240
+ }, [open, activeIndex, submenuKey, menuNode]);
241
+
242
+ function moveActive(delta: number) {
243
+ if (enabledIndexes.length === 0) {
244
+ return;
245
+ }
246
+ const position = enabledIndexes.indexOf(activeIndex);
247
+ setActiveIndex(enabledIndexes[(position + delta + enabledIndexes.length) % enabledIndexes.length]);
248
+ }
249
+
250
+ function handleMenuKeyDown(e: React.KeyboardEvent) {
251
+ if (e.key === "Escape") {
252
+ // Not on to a `Modal` the menu was opened in: Escape closes the menu (or its submenu) only.
253
+ e.stopPropagation();
254
+ }
255
+ if (e.key === "ArrowDown") {
256
+ e.preventDefault();
257
+ moveActive(1);
258
+ } else if (e.key === "ArrowUp") {
259
+ e.preventDefault();
260
+ moveActive(-1);
261
+ } else if (e.key === "Home") {
262
+ e.preventDefault();
263
+ setActiveIndex(enabledIndexes[0] ?? 0);
264
+ } else if (e.key === "End") {
265
+ e.preventDefault();
266
+ setActiveIndex(enabledIndexes[enabledIndexes.length - 1] ?? 0);
267
+ } else if (e.key === "ArrowRight" && items[activeIndex]?.submenu) {
268
+ e.preventDefault();
269
+ enterSubmenu(items[activeIndex]);
270
+ } else if ((e.key === "ArrowLeft" || e.key === "Escape") && openSubmenu) {
271
+ // ARIA's own submenu behaviour: Escape leaves the submenu for its parent menu rather than
272
+ // dismissing the whole thing.
273
+ e.preventDefault();
274
+ leaveSubmenu();
275
+ } else if (e.key === "Escape" || e.key === "Tab") {
276
+ e.preventDefault();
277
+ closeMenu(true);
278
+ }
279
+ }
280
+
281
+ function handleTriggerKeyDown(e: React.KeyboardEvent) {
282
+ if (!open && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
283
+ e.preventDefault();
284
+ openMenu();
285
+ }
286
+ }
287
+
288
+ let itemIndex = -1;
289
+
290
+ return (
291
+ <>
292
+ <button
293
+ ref={triggerRef}
294
+ type="button"
295
+ aria-haspopup="menu"
296
+ aria-expanded={open}
297
+ aria-label={ariaLabel}
298
+ title={title}
299
+ disabled={disabled}
300
+ onClick={() => (open ? closeMenu(false) : openMenu())}
301
+ onKeyDown={handleTriggerKeyDown}
302
+ className={
303
+ iconOnly
304
+ ? className
305
+ : [
306
+ // `min-w-0` so a long label ("Filter: Has attachments") truncates instead of pushing
307
+ // whatever sits beside it in the toolbar off the edge of a 384px list column.
308
+ "inline-flex min-w-0 items-center gap-1 px-2 py-1 rounded-md text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent",
309
+ className,
310
+ ].join(" ")
311
+ }
312
+ >
313
+ {icon}
314
+ {!iconOnly && <span className="truncate">{label}</span>}
315
+ {!iconOnly && <HiChevronDown size={14} aria-hidden="true" className="shrink-0 text-text-muted" />}
316
+ </button>
317
+ {open && (
318
+ <PopoverPortal
319
+ anchorRef={triggerRef}
320
+ onClose={() => closeMenu(false)}
321
+ width={width}
322
+ height={menuHeight(shownSections, width)}
323
+ aria-label={ariaLabel}
324
+ >
325
+ <div
326
+ ref={setMenuNode}
327
+ role="menu"
328
+ tabIndex={-1}
329
+ aria-label={ariaLabel}
330
+ onKeyDown={handleMenuKeyDown}
331
+ className="flex-1 overflow-y-auto py-1"
332
+ >
333
+ {shownSections.map((section, sectionIndex) => (
334
+ <div key={section.key} role="group" aria-label={section.label} className={sectionIndex > 0 ? "border-t border-border mt-1 pt-1" : ""}>
335
+ {section.label && (
336
+ <div aria-hidden="true" className="h-6 flex items-center px-3 text-xs font-semibold uppercase tracking-wide text-text-muted">
337
+ {section.label}
338
+ </div>
339
+ )}
340
+ {section.items.map((item) => {
341
+ itemIndex += 1;
342
+ const index = itemIndex;
343
+ const role = item.role ?? "menuitem";
344
+ return (
345
+ <button
346
+ key={item.key}
347
+ ref={(node) => {
348
+ itemRefs.current[index] = node;
349
+ }}
350
+ type="button"
351
+ role={role}
352
+ disabled={item.disabled}
353
+ title={item.title}
354
+ tabIndex={index === activeIndex ? 0 : -1}
355
+ {...(role === "menuitem"
356
+ ? {}
357
+ : { "aria-checked": item.checked === "mixed" ? ("mixed" as const) : !!item.checked })}
358
+ {...(item.submenu ? { "aria-haspopup": "menu" as const, "aria-expanded": false } : {})}
359
+ onClick={() => selectItem(item)}
360
+ className="w-full flex items-start gap-2 px-3 py-2 text-left text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent"
361
+ >
362
+ <span className="w-4 shrink-0 flex justify-center pt-0.5">
363
+ {item.key === "__back" && (
364
+ <HiChevronLeft size={14} aria-hidden="true" className="text-text-muted" />
365
+ )}
366
+ {item.checked === "mixed" ? (
367
+ <HiMinus size={14} aria-hidden="true" className="text-primary-dark" />
368
+ ) : (
369
+ item.checked && <HiCheck size={14} aria-hidden="true" className="text-primary-dark" />
370
+ )}
371
+ </span>
372
+ <span className="min-w-0 flex-1">
373
+ <span className="flex h-5 items-center gap-1.5">
374
+ {item.icon && (
375
+ <span aria-hidden="true" className="shrink-0 text-text-muted">
376
+ {item.icon}
377
+ </span>
378
+ )}
379
+ {item.swatchColor && (
380
+ <span
381
+ aria-hidden="true"
382
+ className="w-2.5 h-2.5 rounded-full shrink-0"
383
+ style={{ backgroundColor: item.swatchColor }}
384
+ />
385
+ )}
386
+ <span className="truncate">{item.label}</span>
387
+ </span>
388
+ {item.description && (
389
+ <span className="block h-4 text-xs leading-4 text-text-muted truncate font-normal">
390
+ {item.description}
391
+ </span>
392
+ )}
393
+ </span>
394
+ {item.submenu && (
395
+ <HiChevronRight size={14} aria-hidden="true" className="shrink-0 mt-0.5 text-text-muted" />
396
+ )}
397
+ </button>
398
+ );
399
+ })}
400
+ {section.note && <p className="px-3 py-1 text-xs leading-4 text-text-muted">{section.note}</p>}
401
+ </div>
402
+ ))}
403
+ </div>
404
+ </PopoverPortal>
405
+ )}
406
+ </>
407
+ );
408
+ }
@@ -2,7 +2,7 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import React, { useEffect, useRef, useState } from "react";
5
+ import React, { useEffect, useId, useRef, useState } from "react";
6
6
  import type { IconType } from "react-icons";
7
7
  import { HiOutlineEllipsisHorizontal } from "react-icons/hi2";
8
8
  import NavBadge, { navItemLabel } from "./NavBadge.js";
@@ -53,13 +53,24 @@ const TAB_CLASS = "flex-1 min-w-0 overflow-hidden px-1 flex flex-col items-cente
53
53
  * `hidden md:flex` there) — never both hidden or both visible at once.
54
54
  *
55
55
  * The entries share the width equally and never run off the screen: with more than `MAX_TABS`, the last slot is a "More" (…) button that opens a menu
56
- * of the others (see `splitTabs()`). The menu closes on Escape, on a click elsewhere and on choosing an entry.
56
+ * of the others (see `splitTabs()`). That is a plain disclosure - a button (`aria-expanded`, `aria-controls`) and a list of links, all reached by Tab - not an ARIA menu,
57
+ * which would owe its arrow-key navigation. It closes on Escape, on a click elsewhere, when focus tabs out of it and on choosing an entry; opened from the keyboard,
58
+ * it moves focus to its first link.
57
59
  */
58
60
  export default function BottomTabBar({ apps, active }: BottomTabBarProps) {
59
61
  const { shown, more } = splitTabs(apps, active);
60
62
  const [open, setOpen] = useState(false);
61
63
  const rootRef = useRef<HTMLDivElement>(null);
62
64
  const buttonRef = useRef<HTMLButtonElement>(null);
65
+ const popupId = useId();
66
+ // Whether the press that opened it was the keyboard's (a click with no pointer has a `detail` of 0).
67
+ const openedByKeyboard = useRef(false);
68
+
69
+ useEffect(() => {
70
+ if (open && openedByKeyboard.current) {
71
+ rootRef.current!.querySelector("a")!.focus();
72
+ }
73
+ }, [open]);
63
74
 
64
75
  useEffect(() => {
65
76
  if (!open) {
@@ -107,14 +118,26 @@ export default function BottomTabBar({ apps, active }: BottomTabBarProps) {
107
118
  </a>
108
119
  ))}
109
120
  {more.length > 0 && (
110
- <div ref={rootRef} className="flex-1 min-w-0 flex items-stretch relative">
121
+ <div
122
+ ref={rootRef}
123
+ className="flex-1 min-w-0 flex items-stretch relative"
124
+ // Focus moved on to something outside (not merely lost, as a tap on a link does in a browser that does not focus links).
125
+ onBlur={(event) => {
126
+ if (event.relatedTarget instanceof Node && !event.currentTarget.contains(event.relatedTarget)) {
127
+ setOpen(false);
128
+ }
129
+ }}
130
+ >
111
131
  <button
112
132
  ref={buttonRef}
113
133
  type="button"
114
134
  aria-label={hiddenBadge ? "More, with unread items" : "More"}
115
- aria-haspopup="menu"
116
135
  aria-expanded={open}
117
- onClick={() => setOpen((value) => !value)}
136
+ aria-controls={open ? popupId : undefined}
137
+ onClick={(event) => {
138
+ openedByKeyboard.current = event.detail === 0;
139
+ setOpen((value) => !value);
140
+ }}
118
141
  className={[TAB_CLASS, "w-full", open ? "text-primary-dark" : "text-text-muted"].join(" ")}
119
142
  >
120
143
  <span className="relative inline-flex">
@@ -124,11 +147,10 @@ export default function BottomTabBar({ apps, active }: BottomTabBarProps) {
124
147
  <span>More</span>
125
148
  </button>
126
149
  {open && (
127
- <ul role="menu" aria-label="More" className="absolute bottom-full right-1 mb-2 min-w-48 max-w-[calc(100vw-1rem)] rounded-md border border-border bg-surface py-1 shadow-modal">
150
+ <ul id={popupId} aria-label="More" className="absolute bottom-full right-1 mb-2 min-w-48 max-w-[calc(100vw-1rem)] rounded-md border border-border bg-surface py-1 shadow-modal">
128
151
  {more.map(({ id, href, label, icon: Icon, badge }) => (
129
- <li key={id} role="none">
152
+ <li key={id}>
130
153
  <a
131
- role="menuitem"
132
154
  href={href}
133
155
  aria-label={badge ? navItemLabel(label, badge) : undefined}
134
156
  onClick={() => setOpen(false)}
@@ -25,7 +25,8 @@ export interface PopoverPortalProps {
25
25
  * isn't a stacking-order problem `z-index` can override). Prefers opening *below* the anchor (there's
26
26
  * more room in the editor area below the toolbar than above it, where the To/Cc/Subject rows are —
27
27
  * confirmed directly: an earlier `absolute`+`overflow-hidden` version of this rendered the popup
28
- * behind those rows), flipping to *above* only if there isn't enough room below.
28
+ * behind those rows), flipping to *above* only if there isn't enough room below. Its `z-index` is above a `Modal`/`Drawer` backdrop (`z-[1000]`), so a popover opened from inside
29
+ * a dialog (the contact card's photo menu) is not hidden behind it.
29
30
  */
30
31
  export default function PopoverPortal({ anchorRef, onClose, width, height, children, ...rest }: PropsWithChildren<PopoverPortalProps>) {
31
32
  const containerRef = useRef<HTMLDivElement>(null);
@@ -66,7 +67,7 @@ export default function PopoverPortal({ anchorRef, onClose, width, height, child
66
67
  }
67
68
 
68
69
  return createPortal(
69
- <div ref={containerRef} role="dialog" style={style} className="z-50 flex flex-col bg-surface border border-border rounded-md shadow-modal overflow-hidden" {...rest}>
70
+ <div ref={containerRef} role="dialog" style={style} className="z-[1010] flex flex-col bg-surface border border-border rounded-md shadow-modal overflow-hidden" {...rest}>
70
71
  {children}
71
72
  </div>,
72
73
  document.body,
@@ -67,6 +67,13 @@ export default function TimeZonePicker({ id, "aria-label": label, "aria-describe
67
67
  return () => document.removeEventListener("pointerdown", outside);
68
68
  }, [open]);
69
69
 
70
+ // The arrow keys move `active` (and `aria-activedescendant`) only: the list scrolls, so the zone they reach has to be brought into view.
71
+ useEffect(() => {
72
+ if (open && active >= 0) {
73
+ document.getElementById(`${listId}-${active}`)?.scrollIntoView?.({ block: "nearest" });
74
+ }
75
+ }, [open, active]);
76
+
70
77
  function close(refocus: boolean) {
71
78
  setOpen(false);
72
79
  setQuery("");
@@ -108,7 +115,16 @@ export default function TimeZonePicker({ id, "aria-label": label, "aria-describe
108
115
  }
109
116
 
110
117
  return (
111
- <div ref={root} className="relative inline-block max-w-full">
118
+ <div
119
+ ref={root}
120
+ className="relative inline-block max-w-full"
121
+ // Tabbing away leaves the list open otherwise (only a press outside closed it). Focus that goes nowhere in particular (the window losing it) is not leaving.
122
+ onBlur={(event) => {
123
+ if (open && event.relatedTarget instanceof Node && !root.current?.contains(event.relatedTarget)) {
124
+ close(false);
125
+ }
126
+ }}
127
+ >
112
128
  <button
113
129
  ref={trigger}
114
130
  id={id}
@@ -5,8 +5,8 @@
5
5
  /** Typed wrappers over `@rapidmx/restapi`'s `/mail/contacts` REST surface — see `mailApi.ts`'s own header
6
6
  * comment for the shared ACL/authorization model every wrapper file here follows. */
7
7
 
8
- import { ApiClient, ApiRequestError, apiUrl, withClient, withCsrfHeader } from "../util/api.js";
9
- import { ListParams, buildQuery } from "../util/apiQuery.js";
8
+ import { ApiClient, apiUrl, withClient, withClientRaw } from "../util/api.js";
9
+ import { ListParams, SETTINGS_LIST_LIMIT, buildQuery } from "../util/apiQuery.js";
10
10
  import {
11
11
  type EncryptionPreference,
12
12
  type KeyConflict,
@@ -83,10 +83,14 @@ export interface Contact {
83
83
  rejectedKeys?: RejectedKey[];
84
84
  }
85
85
 
86
+ /** The order a contacts list is read in: alphabetical, with the `uid` as the tie-breaker so contacts sharing a name keep one fixed order and
87
+ * a page boundary between them can neither skip nor repeat one. */
88
+ const CONTACT_SORT = JSON.stringify({ displayName: "ASC", uid: "ASC" });
89
+
86
90
  /** Lists a folder's contacts, alphabetically by display name. Never includes soft-deleted contacts — see
87
91
  * `listDeletedContacts()` for those. */
88
92
  export function listContacts(folderUid: string, params: ListParams = {}, client?: ApiClient): Promise<Contact[]> {
89
- return withClient(client, `/mail/contacts?${buildQuery(params, { folderUid, sort: JSON.stringify({ displayName: "ASC" }) })}`);
93
+ return withClient(client, `/mail/contacts?${buildQuery(params, { folderUid, sort: CONTACT_SORT })}`);
90
94
  }
91
95
 
92
96
  function contactsMatching(contacts: Contact[], address: string): Contact[] {
@@ -195,7 +199,7 @@ export async function fetchSignerKeyState(folderUids: string[], address: string,
195
199
  export function listDeletedContacts(folderUid: string, params: ListParams = {}, client?: ApiClient): Promise<Contact[]> {
196
200
  return withClient(
197
201
  client,
198
- `/mail/contacts?${buildQuery(params, { folderUid, deleted: "true", sort: JSON.stringify({ displayName: "ASC" }) })}`,
202
+ `/mail/contacts?${buildQuery(params, { folderUid, deleted: "true", sort: CONTACT_SORT })}`,
199
203
  );
200
204
  }
201
205
 
@@ -254,7 +258,8 @@ export function setContactFavorite(contact: Contact, favorite: boolean, client?:
254
258
 
255
259
  /**
256
260
  * The URL of a contact's own picture, or `undefined` when they have none. The version is part of it so a replaced picture is fetched again rather than
257
- * served from the browser's cache.
261
+ * served from the browser's cache. Only an `<img src>` of the cookie-authenticated web app can load it: under an explicit `ApiClient` use
262
+ * `useContactPhotoSrc()` instead.
258
263
  */
259
264
  export function contactPhotoUrl(contact: Pick<Contact, "uid" | "version" | "photoBlobKey">): string | undefined {
260
265
  return contact.photoBlobKey ? apiUrl(`/mail/contacts/${encodeURIComponent(contact.uid)}/photo?v=${contact.version}`) : undefined;
@@ -265,19 +270,13 @@ export const CONTACT_PHOTO_TYPES = ["image/jpeg", "image/png", "image/gif", "ima
265
270
  export const CONTACT_PHOTO_MAX_BYTES = 1024 * 1024;
266
271
 
267
272
  /** Sets a contact's own picture from an image file (the file's own bytes go up, not JSON) and returns the updated contact. */
268
- export async function uploadContactPhoto(uid: string, version: number, file: Blob): Promise<Contact> {
269
- const res = await fetch(apiUrl(`/mail/contacts/${encodeURIComponent(uid)}/photo?version=${version}`), {
270
- method: "PUT",
271
- credentials: "include",
272
- headers: withCsrfHeader({ "Content-Type": file.type }, "PUT"),
273
- body: file,
274
- });
275
- const contentType = res.headers.get("content-type") ?? "";
276
- const body = contentType.includes("application/json") ? await res.json().catch(() => undefined) : undefined;
277
- if (!res.ok) {
278
- throw new ApiRequestError((body && (body.message || body.error)) || res.statusText || "Upload failed.", res.status, body?.code);
279
- }
280
- return body as Contact;
273
+ export function uploadContactPhoto(uid: string, version: number, file: Blob, client?: ApiClient): Promise<Contact> {
274
+ return withClientRaw(client, `/mail/contacts/${encodeURIComponent(uid)}/photo?version=${version}`, "PUT", file);
275
+ }
276
+
277
+ /** The bytes of a contact's own picture through an explicit `client` - for one that authenticates with a token, whose `<img>` cannot (see `contactPhotoUrl()`). */
278
+ export function fetchContactPhoto(uid: string, version: number, client: ApiClient): Promise<Blob> {
279
+ return client.fetchBlob(`/mail/contacts/${encodeURIComponent(uid)}/photo?v=${version}`);
281
280
  }
282
281
 
283
282
  /** Removes a contact's own picture and returns the updated contact. */
@@ -300,7 +299,7 @@ export interface ContactList {
300
299
 
301
300
  /** Lists a mailbox's contact lists (Outlook-style "Your contact lists"), alphabetically by name. */
302
301
  export function listContactLists(mailboxUid: string, params: ListParams = {}, client?: ApiClient): Promise<ContactList[]> {
303
- return withClient(client, `/mail/contact-lists?${buildQuery(params, { mailboxUid, sort: JSON.stringify({ name: "ASC" }) })}`);
302
+ return withClient(client, `/mail/contact-lists?${buildQuery({ limit: SETTINGS_LIST_LIMIT, ...params }, { mailboxUid, sort: JSON.stringify({ name: "ASC", uid: "ASC" }) })}`);
304
303
  }
305
304
 
306
305
  export function createContactList(input: { mailboxUid: string; name: string }, client?: ApiClient): Promise<ContactList> {