@rapidmx/web-client 0.30.0 → 0.32.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 (402) 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 +3 -3
  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/diagnostics/diagnosticsApi.ts +2 -2
  33. package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +4 -4
  34. package/apps/shared/components/admin/elevation.ts +19 -0
  35. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +58 -3
  36. package/apps/shared/components/admin/layout/AdminShell.tsx +6 -1
  37. package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +3 -3
  38. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +4 -4
  39. package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +4 -4
  40. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  41. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +6 -6
  42. package/apps/shared/components/admin/settings/BrandingForm.tsx +22 -9
  43. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -4
  44. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +33 -4
  45. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +3 -3
  46. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +8 -3
  47. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +3 -3
  48. package/apps/shared/components/admin/settings/PluginsManager.tsx +195 -46
  49. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +25 -12
  50. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +40 -7
  51. package/apps/shared/components/admin/setup/SetupWizard.tsx +8 -6
  52. package/apps/shared/components/admin/signOut.ts +3 -0
  53. package/apps/shared/components/admin/usePagedList.tsx +2 -2
  54. package/apps/shared/components/calendar/DescriptionEditor.tsx +2 -1
  55. package/apps/shared/components/calendar/EventDetails.tsx +34 -8
  56. package/apps/shared/components/calendar/EventEditor.tsx +90 -40
  57. package/apps/shared/components/calendar/EventFormParts.tsx +5 -2
  58. package/apps/shared/components/calendar/EventMatchNav.tsx +14 -2
  59. package/apps/shared/components/calendar/EventModal.tsx +25 -1
  60. package/apps/shared/components/calendar/FindATime.tsx +10 -2
  61. package/apps/shared/components/calendar/ListView.tsx +99 -25
  62. package/apps/shared/components/calendar/RecurrenceEditor.tsx +19 -8
  63. package/apps/shared/components/calendar/SplitDayView.tsx +14 -18
  64. package/apps/shared/components/calendar/TaskQuickForm.tsx +3 -2
  65. package/apps/shared/components/calendar/TimeGridView.tsx +35 -19
  66. package/apps/shared/components/calendar/gridLayout.ts +94 -0
  67. package/apps/shared/components/calendar/layout/CalendarShell.tsx +1 -1
  68. package/apps/shared/components/calendar/timePicker.ts +5 -2
  69. package/apps/shared/components/contacts/ContactCard.tsx +15 -4
  70. package/apps/shared/components/contacts/ContactDetailPane.tsx +54 -15
  71. package/apps/shared/components/contacts/ContactForm.tsx +61 -17
  72. package/apps/shared/components/contacts/ContactPhotoEditor.tsx +88 -0
  73. package/apps/shared/components/contacts/ContactPhotoField.tsx +36 -39
  74. package/apps/shared/components/contacts/layout/ContactsShell.tsx +1 -1
  75. package/apps/shared/components/contacts/participantDetails.ts +18 -1
  76. package/apps/shared/components/layout/AppShell.tsx +9 -0
  77. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +9 -4
  78. package/apps/shared/components/layout/MailboxProvisioning.tsx +31 -3
  79. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +4 -2
  80. package/apps/shared/components/mail/ConversationList.tsx +49 -6
  81. package/apps/shared/components/mail/ConversationThreadPane.tsx +1 -1
  82. package/apps/shared/components/mail/MenuButton.tsx +3 -400
  83. package/apps/shared/components/mail/MessageDetailPane.tsx +16 -13
  84. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +11 -1
  85. package/apps/shared/components/mail/compose/ComposeWindow.tsx +57 -11
  86. package/apps/shared/components/mail/compose/GifPicker.tsx +10 -4
  87. package/apps/shared/components/mail/compose/RecipientInput.tsx +9 -7
  88. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +14 -1
  89. package/apps/shared/components/mail/compose/encryptionRequirement.ts +5 -0
  90. package/apps/shared/components/mail/compose/quotedBody.ts +31 -6
  91. package/apps/shared/components/mail/layout/MailShell.tsx +1 -1
  92. package/apps/shared/components/mail/reading/MessageBody.tsx +13 -7
  93. package/apps/shared/components/mail/reading/bodyContent.ts +2 -0
  94. package/apps/shared/components/mail/reading/bodyHtml.ts +25 -2
  95. package/apps/shared/components/mail/writableMailboxes.ts +2 -0
  96. package/apps/shared/components/rules/RuleBuilder.tsx +11 -2
  97. package/apps/shared/components/settings/GravatarSetting.tsx +27 -0
  98. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -1
  99. package/apps/shared/components/tasks/dueDate.ts +29 -0
  100. package/apps/shared/components/tasks/layout/TasksShell.tsx +1 -1
  101. package/apps/shared/mail/decryptedMessages.ts +51 -2
  102. package/apps/shared/mail/listSnapshots.ts +2 -0
  103. package/apps/shared/mail/outbox/sendDecision.ts +14 -3
  104. package/apps/shared/mail/outbox/sendJob.ts +5 -0
  105. package/apps/shared/mail/useMailLiveUpdates.ts +7 -1
  106. package/apps/shared/mail/usePermanentDelete.tsx +3 -1
  107. package/apps/shared/notifications/apiErrors.ts +2 -0
  108. package/apps/shared/notifications/preferences.ts +1 -1
  109. package/apps/shared/notifications/store.ts +3 -1
  110. package/apps/shared/plugins/pluginNav.ts +4 -2
  111. package/apps/www/calendar/index.tsx +24 -12
  112. package/apps/www/contacts/[uid].tsx +6 -1
  113. package/apps/www/contacts/index.tsx +66 -12
  114. package/apps/www/index.tsx +91 -28
  115. package/apps/www/messages/[uid].tsx +8 -3
  116. package/apps/www/settings/auto-reply/index.tsx +13 -0
  117. package/apps/www/settings/blocked-senders/index.tsx +4 -0
  118. package/apps/www/settings/encryption/index.tsx +66 -4
  119. package/apps/www/settings/privacy/index.tsx +4 -1
  120. package/apps/www/settings/profile/index.tsx +12 -1
  121. package/apps/www/settings/sharing/index.tsx +11 -1
  122. package/apps/www/settings/signatures/[uid].tsx +6 -4
  123. package/apps/www/tasks/index.tsx +97 -29
  124. package/dist/apps/admin/audit-log/index.js +3 -3
  125. package/dist/apps/admin/branding/index.js +3 -3
  126. package/dist/apps/admin/data-requests/index.js +17 -7
  127. package/dist/apps/admin/distribution-lists/[uid].js +3 -3
  128. package/dist/apps/admin/distribution-lists/index.js +3 -3
  129. package/dist/apps/admin/distribution-lists/new/index.js +3 -3
  130. package/dist/apps/admin/domains/[uid].js +6 -5
  131. package/dist/apps/admin/domains/index.js +3 -3
  132. package/dist/apps/admin/domains/new/index.js +3 -3
  133. package/dist/apps/admin/escrow-scopes/[uid].js +11 -6
  134. package/dist/apps/admin/escrow-scopes/index.js +3 -3
  135. package/dist/apps/admin/escrow-scopes/new/index.js +9 -4
  136. package/dist/apps/admin/index.js +4 -4
  137. package/dist/apps/admin/ingest-queue/index.js +3 -3
  138. package/dist/apps/admin/mailboxes/[uid].js +15 -7
  139. package/dist/apps/admin/quarantine/index.js +4 -4
  140. package/dist/apps/admin/retention-policy/index.js +3 -3
  141. package/dist/apps/admin/signing-certificates/index.js +3 -3
  142. package/dist/apps/admin/transport-rules/[uid].js +4 -4
  143. package/dist/apps/admin/transport-rules/index.js +3 -3
  144. package/dist/apps/admin/transport-rules/new/index.js +3 -3
  145. package/dist/apps/escrow/matters/[uid].js +8 -3
  146. package/dist/apps/escrow/matters/new/index.js +4 -0
  147. package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +11 -7
  148. package/dist/apps/shared/calendar/calendarLiveUpdates.js +10 -9
  149. package/dist/apps/shared/calendar/useCalendarReminders.js +2 -0
  150. package/dist/apps/shared/components/admin/ActionAlert.d.ts +13 -0
  151. package/dist/apps/shared/components/admin/ActionAlert.js +24 -0
  152. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +2 -2
  153. package/dist/apps/shared/components/admin/diagnostics/InstalledPlugins.js +1 -1
  154. package/dist/apps/shared/components/admin/diagnostics/LogsPanel.js +1 -1
  155. package/dist/apps/shared/components/admin/diagnostics/RuntimePanel.js +1 -1
  156. package/dist/apps/shared/components/admin/diagnostics/SystemPanel.js +1 -1
  157. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +2 -2
  158. package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +4 -4
  159. package/dist/apps/shared/components/admin/elevation.d.ts +9 -0
  160. package/dist/apps/shared/components/admin/elevation.js +16 -0
  161. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.d.ts +2 -0
  162. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +41 -1
  163. package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -1
  164. package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.js +3 -3
  165. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +4 -4
  166. package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.js +4 -4
  167. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  168. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +6 -6
  169. package/dist/apps/shared/components/admin/settings/BrandingForm.js +17 -7
  170. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +4 -4
  171. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +17 -5
  172. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +3 -3
  173. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +8 -3
  174. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +3 -3
  175. package/dist/apps/shared/components/admin/settings/PluginsManager.js +103 -18
  176. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +17 -10
  177. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +18 -8
  178. package/dist/apps/shared/components/admin/setup/SetupWizard.js +8 -6
  179. package/dist/apps/shared/components/admin/signOut.js +3 -0
  180. package/dist/apps/shared/components/admin/usePagedList.js +2 -2
  181. package/dist/apps/shared/components/calendar/DescriptionEditor.js +2 -1
  182. package/dist/apps/shared/components/calendar/EventDetails.d.ts +3 -1
  183. package/dist/apps/shared/components/calendar/EventDetails.js +7 -4
  184. package/dist/apps/shared/components/calendar/EventEditor.js +81 -33
  185. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +3 -1
  186. package/dist/apps/shared/components/calendar/EventFormParts.js +3 -3
  187. package/dist/apps/shared/components/calendar/EventMatchNav.d.ts +1 -1
  188. package/dist/apps/shared/components/calendar/EventMatchNav.js +12 -2
  189. package/dist/apps/shared/components/calendar/EventModal.js +10 -2
  190. package/dist/apps/shared/components/calendar/FindATime.js +4 -3
  191. package/dist/apps/shared/components/calendar/ListView.d.ts +10 -4
  192. package/dist/apps/shared/components/calendar/ListView.js +92 -27
  193. package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +4 -1
  194. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +15 -10
  195. package/dist/apps/shared/components/calendar/SplitDayView.js +11 -17
  196. package/dist/apps/shared/components/calendar/TaskQuickForm.js +3 -2
  197. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -21
  198. package/dist/apps/shared/components/calendar/gridLayout.d.ts +41 -0
  199. package/dist/apps/shared/components/calendar/gridLayout.js +83 -0
  200. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +1 -1
  201. package/dist/apps/shared/components/calendar/timePicker.js +5 -2
  202. package/dist/apps/shared/components/contacts/ContactCard.js +9 -3
  203. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +5 -3
  204. package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -5
  205. package/dist/apps/shared/components/contacts/ContactForm.js +54 -16
  206. package/dist/apps/shared/components/contacts/ContactPhotoEditor.d.ts +21 -0
  207. package/dist/apps/shared/components/contacts/ContactPhotoEditor.js +56 -0
  208. package/dist/apps/shared/components/contacts/ContactPhotoField.d.ts +7 -4
  209. package/dist/apps/shared/components/contacts/ContactPhotoField.js +19 -21
  210. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +1 -1
  211. package/dist/apps/shared/components/contacts/participantDetails.d.ts +4 -1
  212. package/dist/apps/shared/components/contacts/participantDetails.js +17 -1
  213. package/dist/apps/shared/components/layout/AppShell.js +9 -0
  214. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +2 -3
  215. package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +4 -1
  216. package/dist/apps/shared/components/layout/MailboxProvisioning.js +24 -2
  217. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.d.ts +4 -2
  218. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +4 -2
  219. package/dist/apps/shared/components/mail/ConversationList.js +49 -6
  220. package/dist/apps/shared/components/mail/ConversationThreadPane.js +1 -1
  221. package/dist/apps/shared/components/mail/MenuButton.d.ts +2 -78
  222. package/dist/apps/shared/components/mail/MenuButton.js +2 -211
  223. package/dist/apps/shared/components/mail/MessageDetailPane.js +10 -6
  224. package/dist/apps/shared/components/mail/compose/ComposeToolbar.d.ts +3 -0
  225. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +10 -1
  226. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +55 -10
  227. package/dist/apps/shared/components/mail/compose/GifPicker.js +10 -4
  228. package/dist/apps/shared/components/mail/compose/RecipientInput.js +9 -7
  229. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.d.ts +7 -1
  230. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +13 -1
  231. package/dist/apps/shared/components/mail/compose/encryptionRequirement.d.ts +5 -0
  232. package/dist/apps/shared/components/mail/compose/quotedBody.js +28 -5
  233. package/dist/apps/shared/components/mail/layout/MailShell.js +1 -1
  234. package/dist/apps/shared/components/mail/reading/MessageBody.js +11 -6
  235. package/dist/apps/shared/components/mail/reading/bodyContent.js +2 -0
  236. package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +5 -1
  237. package/dist/apps/shared/components/mail/reading/bodyHtml.js +23 -2
  238. package/dist/apps/shared/components/mail/writableMailboxes.js +2 -0
  239. package/dist/apps/shared/components/rules/RuleBuilder.js +9 -1
  240. package/dist/apps/shared/components/settings/GravatarSetting.d.ts +6 -0
  241. package/dist/apps/shared/components/settings/GravatarSetting.js +15 -0
  242. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -1
  243. package/dist/apps/shared/components/tasks/dueDate.d.ts +12 -0
  244. package/dist/apps/shared/components/tasks/dueDate.js +26 -0
  245. package/dist/apps/shared/components/tasks/layout/TasksShell.js +1 -1
  246. package/dist/apps/shared/mail/decryptedMessages.d.ts +8 -0
  247. package/dist/apps/shared/mail/decryptedMessages.js +46 -2
  248. package/dist/apps/shared/mail/listSnapshots.js +2 -0
  249. package/dist/apps/shared/mail/outbox/sendDecision.d.ts +6 -3
  250. package/dist/apps/shared/mail/outbox/sendDecision.js +13 -3
  251. package/dist/apps/shared/mail/outbox/sendJob.d.ts +1 -0
  252. package/dist/apps/shared/mail/outbox/sendJob.js +5 -1
  253. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +3 -0
  254. package/dist/apps/shared/mail/useMailLiveUpdates.js +6 -1
  255. package/dist/apps/shared/mail/usePermanentDelete.d.ts +2 -0
  256. package/dist/apps/shared/mail/usePermanentDelete.js +1 -1
  257. package/dist/apps/shared/notifications/apiErrors.js +2 -0
  258. package/dist/apps/shared/notifications/preferences.d.ts +1 -1
  259. package/dist/apps/shared/notifications/preferences.js +1 -1
  260. package/dist/apps/shared/notifications/store.js +3 -1
  261. package/dist/apps/shared/plugins/pluginNav.d.ts +2 -1
  262. package/dist/apps/shared/plugins/pluginNav.js +4 -2
  263. package/dist/apps/www/calendar/index.js +24 -12
  264. package/dist/apps/www/contacts/[uid].js +6 -2
  265. package/dist/apps/www/contacts/index.js +62 -11
  266. package/dist/apps/www/index.js +85 -30
  267. package/dist/apps/www/messages/[uid].js +8 -3
  268. package/dist/apps/www/settings/auto-reply/index.js +13 -1
  269. package/dist/apps/www/settings/blocked-senders/index.js +4 -0
  270. package/dist/apps/www/settings/encryption/index.js +29 -5
  271. package/dist/apps/www/settings/privacy/index.js +3 -2
  272. package/dist/apps/www/settings/profile/index.js +4 -1
  273. package/dist/apps/www/settings/sharing/index.js +10 -1
  274. package/dist/apps/www/settings/signatures/[uid].js +6 -4
  275. package/dist/apps/www/tasks/index.js +94 -32
  276. package/dist/lib/admin/auditLogApi.js +2 -2
  277. package/dist/lib/admin/distributionListsApi.js +2 -2
  278. package/dist/lib/admin/domainsApi.js +2 -2
  279. package/dist/lib/admin/escrowAuditLogApi.js +2 -2
  280. package/dist/lib/admin/escrowScopesApi.js +2 -2
  281. package/dist/lib/admin/pluginsApi.d.ts +31 -2
  282. package/dist/lib/admin/pluginsApi.js +23 -1
  283. package/dist/lib/admin/retentionPolicyApi.d.ts +3 -0
  284. package/dist/lib/admin/retentionPolicyApi.js +3 -0
  285. package/dist/lib/admin/transportRulesApi.js +2 -2
  286. package/dist/lib/appearance/preferencesApi.d.ts +1 -1
  287. package/dist/lib/appearance/preferencesApi.js +3 -14
  288. package/dist/lib/auth/session.d.ts +51 -12
  289. package/dist/lib/auth/session.js +214 -38
  290. package/dist/lib/branding/brandingApi.d.ts +3 -3
  291. package/dist/lib/branding/brandingApi.js +9 -21
  292. package/dist/lib/calendar/calendarApi.js +3 -1
  293. package/dist/lib/calendar/eventSearch.js +43 -6
  294. package/dist/lib/calendar/freeBusyApi.d.ts +2 -1
  295. package/dist/lib/calendar/freeBusyApi.js +13 -5
  296. package/dist/lib/calendar/recurrence.js +51 -3
  297. package/dist/lib/calendar/useDayKey.d.ts +7 -0
  298. package/dist/lib/calendar/useDayKey.js +43 -0
  299. package/dist/lib/components/avatar/ContactAvatar.d.ts +6 -2
  300. package/dist/lib/components/avatar/ContactAvatar.js +37 -8
  301. package/dist/lib/components/avatar/ContactPhotoAvatar.d.ts +11 -0
  302. package/dist/lib/components/avatar/ContactPhotoAvatar.js +18 -0
  303. package/dist/lib/components/forms/CameraCaptureModal.d.ts +17 -0
  304. package/dist/lib/components/forms/CameraCaptureModal.js +88 -0
  305. package/dist/lib/components/forms/ImageEditBadge.d.ts +28 -0
  306. package/dist/lib/components/forms/ImageEditBadge.js +62 -0
  307. package/dist/lib/components/menus/MenuButton.d.ts +78 -0
  308. package/dist/lib/components/menus/MenuButton.js +219 -0
  309. package/dist/lib/components/navigation/BottomTabBar.d.ts +3 -1
  310. package/dist/lib/components/navigation/BottomTabBar.js +22 -3
  311. package/dist/lib/components/overlays/PopoverPortal.d.ts +2 -1
  312. package/dist/lib/components/overlays/PopoverPortal.js +3 -2
  313. package/dist/lib/components/pickers/TimeZonePicker.js +13 -1
  314. package/dist/lib/contacts/contactsApi.d.ts +5 -2
  315. package/dist/lib/contacts/contactsApi.js +16 -19
  316. package/dist/lib/contacts/gravatar.d.ts +14 -3
  317. package/dist/lib/contacts/gravatar.js +42 -5
  318. package/dist/lib/contacts/preparePhoto.d.ts +24 -0
  319. package/dist/lib/contacts/preparePhoto.js +250 -0
  320. package/dist/lib/contacts/useContactPhotoSrc.d.ts +19 -0
  321. package/dist/lib/contacts/useContactPhotoSrc.js +153 -0
  322. package/dist/lib/contacts/vcard.d.ts +3 -0
  323. package/dist/lib/contacts/vcard.js +59 -7
  324. package/dist/lib/crypto/escrowKeys.d.ts +8 -0
  325. package/dist/lib/crypto/escrowKeys.js +20 -1
  326. package/dist/lib/crypto/messageSecurity.d.ts +7 -4
  327. package/dist/lib/crypto/messageSecurity.js +23 -5
  328. package/dist/lib/crypto/passwordUnlock.d.ts +6 -1
  329. package/dist/lib/crypto/passwordUnlock.js +13 -2
  330. package/dist/lib/crypto/smime.js +12 -2
  331. package/dist/lib/mail/compose/composeQuoting.js +3 -2
  332. package/dist/lib/mail/compose/quotedImages.d.ts +6 -3
  333. package/dist/lib/mail/compose/quotedImages.js +35 -9
  334. package/dist/lib/mail/labelsApi.js +2 -2
  335. package/dist/lib/mail/mailApi.d.ts +8 -5
  336. package/dist/lib/mail/mailApi.js +73 -39
  337. package/dist/lib/mail/mailDetailHooks.js +11 -3
  338. package/dist/lib/mail/mailFilterRulesApi.js +2 -2
  339. package/dist/lib/mail/mailSignaturesApi.js +2 -2
  340. package/dist/lib/mail/mailboxImportApi.d.ts +1 -1
  341. package/dist/lib/mail/mailboxImportApi.js +3 -15
  342. package/dist/lib/mail/messageBodySanitizer.d.ts +6 -0
  343. package/dist/lib/mail/messageBodySanitizer.js +44 -1
  344. package/dist/lib/search/queryGrammar.js +12 -4
  345. package/dist/lib/search/searchTier3.d.ts +6 -1
  346. package/dist/lib/search/searchTier3.js +54 -25
  347. package/dist/lib/tasks/tasksApi.js +3 -3
  348. package/dist/lib/util/api.d.ts +26 -4
  349. package/dist/lib/util/api.js +73 -16
  350. package/dist/lib/util/apiQuery.d.ts +11 -0
  351. package/dist/lib/util/apiQuery.js +11 -0
  352. package/dist/lib/util/settleWithLimit.d.ts +5 -0
  353. package/dist/lib/util/settleWithLimit.js +25 -0
  354. package/lib/admin/auditLogApi.ts +2 -2
  355. package/lib/admin/distributionListsApi.ts +2 -2
  356. package/lib/admin/domainsApi.ts +2 -2
  357. package/lib/admin/escrowAuditLogApi.ts +2 -2
  358. package/lib/admin/escrowScopesApi.ts +2 -2
  359. package/lib/admin/pluginsApi.ts +45 -3
  360. package/lib/admin/retentionPolicyApi.ts +4 -0
  361. package/lib/admin/transportRulesApi.ts +2 -2
  362. package/lib/appearance/preferencesApi.ts +3 -14
  363. package/lib/auth/session.ts +239 -39
  364. package/lib/branding/brandingApi.ts +9 -21
  365. package/lib/calendar/calendarApi.ts +3 -1
  366. package/lib/calendar/eventSearch.ts +46 -6
  367. package/lib/calendar/freeBusyApi.ts +14 -6
  368. package/lib/calendar/recurrence.ts +59 -8
  369. package/lib/calendar/useDayKey.ts +45 -0
  370. package/lib/components/avatar/ContactAvatar.tsx +46 -8
  371. package/lib/components/avatar/ContactPhotoAvatar.tsx +26 -0
  372. package/lib/components/forms/CameraCaptureModal.tsx +133 -0
  373. package/lib/components/forms/ImageEditBadge.tsx +121 -0
  374. package/lib/components/menus/MenuButton.tsx +408 -0
  375. package/lib/components/navigation/BottomTabBar.tsx +30 -8
  376. package/lib/components/overlays/PopoverPortal.tsx +3 -2
  377. package/lib/components/pickers/TimeZonePicker.tsx +17 -1
  378. package/lib/contacts/contactsApi.ts +18 -19
  379. package/lib/contacts/gravatar.ts +47 -5
  380. package/lib/contacts/preparePhoto.ts +273 -0
  381. package/lib/contacts/useContactPhotoSrc.ts +177 -0
  382. package/lib/contacts/vcard.ts +60 -7
  383. package/lib/crypto/escrowKeys.ts +21 -1
  384. package/lib/crypto/messageSecurity.ts +33 -9
  385. package/lib/crypto/passwordUnlock.ts +14 -2
  386. package/lib/crypto/smime.ts +13 -2
  387. package/lib/mail/compose/composeQuoting.ts +3 -2
  388. package/lib/mail/compose/quotedImages.ts +39 -9
  389. package/lib/mail/labelsApi.ts +2 -2
  390. package/lib/mail/mailApi.ts +96 -39
  391. package/lib/mail/mailDetailHooks.ts +11 -3
  392. package/lib/mail/mailFilterRulesApi.ts +2 -2
  393. package/lib/mail/mailSignaturesApi.ts +2 -2
  394. package/lib/mail/mailboxImportApi.ts +9 -15
  395. package/lib/mail/messageBodySanitizer.ts +47 -1
  396. package/lib/search/queryGrammar.ts +13 -4
  397. package/lib/search/searchTier3.ts +65 -32
  398. package/lib/tasks/tasksApi.ts +3 -3
  399. package/lib/util/api.ts +99 -16
  400. package/lib/util/apiQuery.ts +13 -0
  401. package/lib/util/settleWithLimit.ts +25 -0
  402. package/package.json +1 -1
@@ -151,6 +151,8 @@ export interface CalendarEvent {
151
151
 
152
152
  const LIST_PAGE_SIZE = 500;
153
153
  const LIST_CALENDAR_EVENTS_MAX_PAGES = 100;
154
+ /** Without an order, a page boundary can skip or repeat an event: the `uid` is a fixed one. */
155
+ const EVENT_SORT = JSON.stringify({ uid: "ASC" });
154
156
 
155
157
  /**
156
158
  * Lists every event in a folder (same "fetch the flat list, filter client-side" contract as
@@ -173,7 +175,7 @@ export async function listCalendarEvents(folderUid: string, client?: ApiClient):
173
175
  for (let page = 0; page < LIST_CALENDAR_EVENTS_MAX_PAGES; page++) {
174
176
  const batch = await withClient<CalendarEvent[]>(
175
177
  client,
176
- `/mail/calendar-events?${buildQuery({ limit: LIST_PAGE_SIZE, page }, { folderUid })}`,
178
+ `/mail/calendar-events?${buildQuery({ limit: LIST_PAGE_SIZE, page }, { folderUid, sort: EVENT_SORT })}`,
177
179
  );
178
180
  events.push(...batch);
179
181
  if (batch.length < LIST_PAGE_SIZE) {
@@ -75,6 +75,37 @@ export function allOccurrences(events: CalendarEvent[], now: Date = new Date()):
75
75
  return occurrencesOf(events, () => true, now);
76
76
  }
77
77
 
78
+ /**
79
+ * The `count` occurrences of `chronological` (a series, in order of start, as `expandOccurrences()` returns it) that start nearest `now`, still in order. They
80
+ * are the run around `now`, so that is grown from it a side at a time (the nearer side first, the earlier one on a tie) rather than sorting the whole series.
81
+ */
82
+ function nearest(chronological: CalendarOccurrence[], now: number, count: number): CalendarOccurrence[] {
83
+ const startOf = (o: CalendarOccurrence) => new Date(o.startDate).getTime();
84
+ // The first that starts at or after now.
85
+ let low = 0;
86
+ let high = chronological.length;
87
+ while (low < high) {
88
+ const middle = (low + high) >> 1;
89
+ if (startOf(chronological[middle]) < now) {
90
+ low = middle + 1;
91
+ } else {
92
+ high = middle;
93
+ }
94
+ }
95
+ let from = low;
96
+ let to = low;
97
+ while (to - from < count) {
98
+ const before = from > 0 ? now - startOf(chronological[from - 1]) : Infinity;
99
+ const after = to < chronological.length ? startOf(chronological[to]) - now : Infinity;
100
+ if (before <= after) {
101
+ from--;
102
+ } else {
103
+ to++;
104
+ }
105
+ }
106
+ return chronological.slice(from, to);
107
+ }
108
+
78
109
  /** The chronological occurrences of the `events` that `include` accepts (see `searchOccurrences()` for how a series is bounded). */
79
110
  function occurrencesOf(events: CalendarEvent[], include: (event: CalendarEvent) => boolean, now: Date): CalendarOccurrence[] {
80
111
  const from = addYears(now, -SEARCH_WINDOW_YEARS);
@@ -84,16 +115,25 @@ function occurrencesOf(events: CalendarEvent[], include: (event: CalendarEvent)
84
115
  if (!include(event)) {
85
116
  continue;
86
117
  }
118
+ const once = (): CalendarOccurrence[] =>
119
+ expandOccurrences(
120
+ { ...event, recurrenceRule: undefined },
121
+ new Date(new Date(event.startDate).getTime() - 1),
122
+ new Date(new Date(event.endDate).getTime() + 1),
123
+ );
87
124
  if (!event.recurrenceRule) {
88
- found.push(...expandOccurrences(event, new Date(new Date(event.startDate).getTime() - 1), new Date(new Date(event.endDate).getTime() + 1)));
125
+ found.push(...once());
89
126
  continue;
90
127
  }
91
- const occurrences = expandOccurrences(event, from, to);
92
- if (occurrences.length > SEARCH_MAX_OCCURRENCES_PER_SERIES) {
93
- const distance = (o: CalendarOccurrence) => Math.abs(new Date(o.startDate).getTime() - now.getTime());
94
- occurrences.sort((a, b) => distance(a) - distance(b)).length = SEARCH_MAX_OCCURRENCES_PER_SERIES;
128
+ let occurrences: CalendarOccurrence[];
129
+ try {
130
+ occurrences = expandOccurrences(event, from, to);
131
+ } catch {
132
+ // One event whose rule or dates cannot be expanded must not fail the whole search or agenda: it counts once, as the stored event.
133
+ found.push(...once());
134
+ continue;
95
135
  }
96
- found.push(...occurrences);
136
+ found.push(...(occurrences.length > SEARCH_MAX_OCCURRENCES_PER_SERIES ? nearest(occurrences, now.getTime(), SEARCH_MAX_OCCURRENCES_PER_SERIES) : occurrences));
97
137
  }
98
138
  return found.sort(byStart);
99
139
  }
@@ -78,18 +78,24 @@ export interface PersonAvailability {
78
78
  /** What a person's calendar says about a stretch of time. `"unknown"` is any person the server did not (or would not) tell about - never free. */
79
79
  export type AvailabilityState = "free" | "tentative" | "busy" | "unknown";
80
80
 
81
- /** The response's results with their times as milliseconds (a person who isn't `"available"` has no blocks whatever the server sent). */
81
+ /** The response's results with their times as milliseconds (a person who isn't `"available"` has no blocks whatever the server sent). A window
82
+ * whose start or end can't be read is busy for all time - never silently free, which is what comparing `NaN` would make it. */
82
83
  export function availabilityOf(response: FreeBusyResponse): PersonAvailability[] {
83
84
  return response.results.map((result) => ({
84
85
  address: result.address,
85
86
  status: result.status,
86
- busy:
87
- result.status === "available"
88
- ? result.busy.map((window) => ({ startMs: Date.parse(window.start), endMs: Date.parse(window.end), tentative: window.tentative }))
89
- : [],
87
+ busy: result.status === "available" ? result.busy.map(toBusyBlock) : [],
90
88
  }));
91
89
  }
92
90
 
91
+ function toBusyBlock(window: { start: string; end: string; tentative: boolean }): BusyBlock {
92
+ const startMs = Date.parse(window.start);
93
+ const endMs = Date.parse(window.end);
94
+ return Number.isFinite(startMs) && Number.isFinite(endMs)
95
+ ? { startMs, endMs, tentative: window.tentative }
96
+ : { startMs: Number.NEGATIVE_INFINITY, endMs: Number.POSITIVE_INFINITY, tentative: window.tentative };
97
+ }
98
+
93
99
  /** Whether a block overlaps the stretch from `startMs` to `endMs` (touching ends do not overlap). */
94
100
  function overlaps(block: BusyBlock, startMs: number, endMs: number): boolean {
95
101
  return block.startMs < endMs && block.endMs > startMs;
@@ -165,6 +171,8 @@ export interface SuggestTimesOptions {
165
171
  }
166
172
 
167
173
  const DEFAULT_STEP_MS = 30 * 60_000;
174
+ /** The finest step `suggestTimes()` takes: a zero or negative one would never advance, a tiny one would test millions of slots. */
175
+ const MIN_STEP_MS = 60_000;
168
176
  const DEFAULT_SUGGESTIONS = 5;
169
177
 
170
178
  /**
@@ -177,7 +185,7 @@ export function suggestTimes(people: PersonAvailability[], options: SuggestTimes
177
185
  if (known.length === 0 || options.durationMs <= 0) {
178
186
  return [];
179
187
  }
180
- const step = options.stepMs ?? DEFAULT_STEP_MS;
188
+ const step = Number.isFinite(options.stepMs) ? Math.max(MIN_STEP_MS, options.stepMs!) : DEFAULT_STEP_MS;
181
189
  const limit = options.limit ?? DEFAULT_SUGGESTIONS;
182
190
  const found: number[] = [];
183
191
  for (const window of [...options.windows].sort((a, b) => a.startMs - b.startMs)) {
@@ -50,10 +50,38 @@ const WEEKDAY_OBJECT: Record<WeekdayCode, Weekday> = {
50
50
  SU: RRule.SU,
51
51
  };
52
52
 
53
+ /** A rule's interval as `rrule` can take it: a whole number of at least 1. A zero, negative, fractional or missing one (an
54
+ * imported or hand-edited rule) would make `rrule` loop forever, or expand to nothing, so it counts as the default 1. */
55
+ function normalizedInterval(interval: number): number {
56
+ return Number.isFinite(interval) && interval >= 1 ? Math.floor(interval) : 1;
57
+ }
58
+
59
+ /** The most days each month (January first) can ever have, counting February as a leap year's. */
60
+ const MAX_DAYS_IN_MONTH = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
61
+
62
+ /**
63
+ * Throws for a rule that can never produce an occurrence: a month outside 1-12, a day of the month outside 1-31 (or -1 to -31), or days
64
+ * that no listed month has (the 30th of February). `rrule` has no way to know that, so `between()` walks every day up to the year 9999
65
+ * looking for one, which freezes the tab for seconds each time such an event is drawn.
66
+ */
67
+ function assertSatisfiable(rule: RecurrenceRule): void {
68
+ const months = rule.byMonth ?? [];
69
+ const days = rule.byMonthDay ?? [];
70
+ if (months.some((m) => !Number.isInteger(m) || m < 1 || m > 12)) {
71
+ throw new Error("The recurrence rule has a month that does not exist");
72
+ }
73
+ if (days.some((d) => !Number.isInteger(d) || d === 0 || Math.abs(d) > 31)) {
74
+ throw new Error("The recurrence rule has a day of the month that does not exist");
75
+ }
76
+ if (months.length > 0 && days.length > 0 && !months.some((m) => days.some((d) => Math.abs(d) <= MAX_DAYS_IN_MONTH[m - 1]))) {
77
+ throw new Error("The recurrence rule names days that none of its months have");
78
+ }
79
+ }
80
+
53
81
  function createRRule(rule: RecurrenceRule, dtstart: Date, until: Date | null, tzid: string | null): RRuleNS.RRule {
54
82
  return new RRule({
55
83
  freq: FREQ_MAP[rule.freq],
56
- interval: rule.interval,
84
+ interval: normalizedInterval(rule.interval),
57
85
  dtstart,
58
86
  tzid,
59
87
  byweekday: rule.byDay?.map((code) => WEEKDAY_OBJECT[code]),
@@ -199,6 +227,21 @@ export function fromEventWallClock(wallMs: number, timezone: string | undefined,
199
227
  return (allDay ? UTC_ZONE : resolveZone(timezone)).fromWall(wallMs);
200
228
  }
201
229
 
230
+ /**
231
+ * `startWall` moved forward by whole repeat periods to just before `from`, for a daily or weekly rule that has no `count`: `rrule`
232
+ * walks every occurrence from the start, so a series that began centuries ago would cost a hundred thousand steps each time it is
233
+ * drawn. A multiple of the period lands on the same days, so the occurrences from `from` on are exactly those of the original.
234
+ * Anything else is returned as it is.
235
+ */
236
+ function advancedStart(rule: RecurrenceRule, startWall: number, from: number): number {
237
+ if (rule.count !== undefined || (rule.freq !== "daily" && rule.freq !== "weekly")) {
238
+ return startWall;
239
+ }
240
+ const period = normalizedInterval(rule.interval) * (rule.freq === "weekly" ? 7 : 1) * MS_PER_DAY;
241
+ const periods = Math.floor((from - startWall) / period) - 1;
242
+ return periods > 0 ? startWall + periods * period : startWall;
243
+ }
244
+
202
245
  /**
203
246
  * Expands `event` into every occurrence whose interval overlaps `[rangeStart, rangeEnd]`. A
204
247
  * non-recurring event yields itself (in a one-element array) if it overlaps, or `[]` otherwise —
@@ -223,22 +266,23 @@ export function expandOccurrences(event: CalendarEvent, rangeStart: Date, rangeE
223
266
  return overlaps ? [{ ...event, occurrenceKey: event.uid, isRecurringOccurrence: false }] : [];
224
267
  }
225
268
 
269
+ assertSatisfiable(event.recurrenceRule);
226
270
  const zone = event.allDay ? UTC_ZONE : resolveZone(event.timezone);
227
271
  const startWall = zone.toWall(start.getTime());
228
272
  // The duration in wall-clock terms, so an occurrence also keeps its local end time across DST.
229
273
  const wallDurationMs = zone.toWall(end.getTime()) - startWall;
230
274
  const { until } = event.recurrenceRule;
231
- const rule = createRRule(
232
- event.recurrenceRule,
233
- new Date(startWall),
234
- until ? new Date(zone.toWall(new Date(until).getTime())) : null,
235
- null,
236
- );
237
275
  // Query in the wall-clock frame, widened by one occurrence's duration (so an occurrence that started
238
276
  // before `rangeStart` but is still in progress isn't missed) plus a day on each side to absorb the
239
277
  // zone's offset; the exact instant-based overlap filter at the end trims the excess.
240
278
  const queryStart = new Date(zone.toWall(rangeStart.getTime()) - wallDurationMs - MS_PER_DAY);
241
279
  const queryEnd = new Date(zone.toWall(rangeEnd.getTime()) + MS_PER_DAY);
280
+ const rule = createRRule(
281
+ event.recurrenceRule,
282
+ new Date(advancedStart(event.recurrenceRule, startWall, queryStart.getTime())),
283
+ until ? new Date(zone.toWall(new Date(until).getTime())) : null,
284
+ null,
285
+ );
242
286
  const occurrenceStarts = rule
243
287
  .between(queryStart, queryEnd, true)
244
288
  .map((occWall) => ({ start: new Date(zone.fromWall(occWall.getTime())), wall: occWall.getTime() }));
@@ -264,7 +308,14 @@ export function expandOccurrences(event: CalendarEvent, rangeStart: Date, rangeE
264
308
 
265
309
  /** Expands every event in `events` and flattens the result — the usual entry point for a grid view. */
266
310
  export function expandAllOccurrences(events: CalendarEvent[], rangeStart: Date, rangeEnd: Date): CalendarOccurrence[] {
267
- return events.flatMap((event) => expandOccurrences(event, rangeStart, rangeEnd));
311
+ return events.flatMap((event) => {
312
+ try {
313
+ return expandOccurrences(event, rangeStart, rangeEnd);
314
+ } catch {
315
+ // One event whose rule or dates cannot be expanded must not blank the whole grid: it is shown once, as the stored event.
316
+ return expandOccurrences({ ...event, recurrenceRule: undefined }, rangeStart, rangeEnd);
317
+ }
318
+ });
268
319
  }
269
320
 
270
321
  /** A human-readable summary of `rule` (e.g. "every 2 weeks on Monday, Wednesday until Jan 1, 2027"). */
@@ -0,0 +1,45 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useEffect, useState } from "react";
6
+ import { addDays, format, startOfDay } from "date-fns";
7
+
8
+ /** How long past local midnight the key is re-read, so a timer that fires a hair early does not land on the day that is ending. */
9
+ const MIDNIGHT_SLACK_MS = 1000;
10
+
11
+ /**
12
+ * Today's local date as `YYYY-MM-DD`, kept current: it ticks over at the next local midnight, so a page left open overnight stops treating yesterday
13
+ * as today (use it as a dependency of whatever was worked out from "now"). It is also re-read when the tab becomes visible again or the window is focused or shown
14
+ * again, as a hidden tab's timers are throttled or suspended (a sleeping laptop, which may wake with the tab still "visible" and so with no `visibilitychange`).
15
+ * Every re-read sets the timer again from the real clock, so a timer that fired a day late cannot leave the key behind.
16
+ */
17
+ export function useDayKey(): string {
18
+ const [dayKey, setDayKey] = useState(() => format(new Date(), "yyyy-MM-dd"));
19
+ useEffect(() => {
20
+ let timer: ReturnType<typeof setTimeout>;
21
+ // Re-reads the day and sets the timer for the midnight after it, replacing any that is pending.
22
+ function arm() {
23
+ clearTimeout(timer);
24
+ const now = new Date();
25
+ setDayKey(format(now, "yyyy-MM-dd"));
26
+ timer = setTimeout(arm, addDays(startOfDay(now), 1).getTime() - now.getTime() + MIDNIGHT_SLACK_MS);
27
+ }
28
+ function onVisible() {
29
+ if (document.visibilityState === "visible") {
30
+ arm();
31
+ }
32
+ }
33
+ arm();
34
+ document.addEventListener("visibilitychange", onVisible);
35
+ window.addEventListener("focus", arm);
36
+ window.addEventListener("pageshow", arm);
37
+ return () => {
38
+ clearTimeout(timer);
39
+ document.removeEventListener("visibilitychange", onVisible);
40
+ window.removeEventListener("focus", arm);
41
+ window.removeEventListener("pageshow", arm);
42
+ };
43
+ }, []);
44
+ return dayKey;
45
+ }
@@ -2,16 +2,20 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import React, { useEffect, useState } from "react";
6
- import { gravatarEnabled, gravatarUrl } from "../../contacts/gravatar.js";
5
+ import React, { useEffect, useRef, useState, useSyncExternalStore } from "react";
6
+ import { gravatarEnabled, gravatarUrl, isGravatarMissing, markGravatarMissing, subscribeGravatarPreference } from "../../contacts/gravatar.js";
7
7
 
8
8
  export interface ContactAvatarProps {
9
9
  displayName: string;
10
10
  size?: number;
11
11
  /** The contact's own picture (see `contactPhotoUrl()`); shown in preference to anything else. */
12
12
  photoUrl?: string;
13
- /** The contact's address: without a picture of their own, its Gravatar is shown when there is one (and the reader hasn't turned that off). */
13
+ /** The contact's address: without a picture of their own, its Gravatar is shown when there is one (and the reader turned that on in Settings > Privacy). */
14
14
  email?: string;
15
+ /** The contact's own picture exists but has not arrived (it is being fetched): Gravatar is not asked about an address whose owner has a picture of their own. */
16
+ photoPending?: boolean;
17
+ /** Called once, when the avatar scrolls into view - for a picture that is only fetched by then. */
18
+ onVisible?: () => void;
15
19
  }
16
20
 
17
21
  /** A fixed palette of background colors, cycled by a hash of the contact's name, for the initials shown when there is no picture. */
@@ -39,21 +43,47 @@ function colorOf(displayName: string): string {
39
43
  * A small circular avatar for a contact, matching Outlook People's: the contact's own picture when they have one, else their Gravatar (when `email`
40
44
  * is given), else initials on a color that is deterministic per name. A picture that fails to load falls through to the next.
41
45
  */
42
- export default function ContactAvatar({ displayName, size = 32, photoUrl, email }: ContactAvatarProps) {
46
+ export default function ContactAvatar({ displayName, size = 32, photoUrl, email, photoPending, onVisible }: ContactAvatarProps) {
43
47
  const [gravatar, setGravatar] = useState<string | undefined>();
44
48
  const [failed, setFailed] = useState<string[]>([]);
49
+ const enabled = useSyncExternalStore(subscribeGravatarPreference, gravatarEnabled, () => false);
50
+ // Without IntersectionObserver there is no telling what is on screen, so look up at once.
51
+ const [visible, setVisible] = useState(() => typeof IntersectionObserver === "undefined");
52
+ const placeholder = useRef<HTMLSpanElement>(null);
53
+ const ownPicture = photoPending || (!!photoUrl && !failed.includes(photoUrl));
54
+
55
+ // A new picture gets a fresh start, whatever failed to load before.
56
+ useEffect(() => {
57
+ setFailed((list) => (list.length ? [] : list));
58
+ }, [photoUrl]);
59
+
60
+ // Only an avatar that is on screen (and has no picture of its own to show) asks Gravatar, so a long list does not send a request per row.
61
+ useEffect(() => {
62
+ const element = placeholder.current;
63
+ if (visible || !element || !(onVisible || (enabled && email && !ownPicture))) {
64
+ return;
65
+ }
66
+ const observer = new IntersectionObserver((entries) => {
67
+ if (entries.some((entry) => entry.isIntersecting)) {
68
+ setVisible(true);
69
+ onVisible?.();
70
+ }
71
+ });
72
+ observer.observe(element);
73
+ return () => observer.disconnect();
74
+ }, [visible, enabled, email, ownPicture, onVisible]);
45
75
 
46
76
  useEffect(() => {
47
77
  setGravatar(undefined);
48
- if (!email || !gravatarEnabled()) {
78
+ if (!email || !enabled || !visible || ownPicture) {
49
79
  return;
50
80
  }
51
81
  let cancelled = false;
52
- void gravatarUrl(email, size * 2).then((url) => !cancelled && setGravatar(url));
82
+ void gravatarUrl(email, size * 2).then((url) => !cancelled && setGravatar(url && !isGravatarMissing(url) ? url : undefined));
53
83
  return () => {
54
84
  cancelled = true;
55
85
  };
56
- }, [email, size]);
86
+ }, [email, size, enabled, visible, ownPicture]);
57
87
 
58
88
  const src = [photoUrl, gravatar].find((url) => url && !failed.includes(url));
59
89
  if (src) {
@@ -65,7 +95,14 @@ export default function ContactAvatar({ displayName, size = 32, photoUrl, email
65
95
  width={size}
66
96
  height={size}
67
97
  referrerPolicy="no-referrer"
68
- onError={() => setFailed((list) => [...list, src])}
98
+ loading="lazy"
99
+ decoding="async"
100
+ onError={() => {
101
+ if (src === gravatar) {
102
+ markGravatarMissing(src);
103
+ }
104
+ setFailed((list) => [...list, src]);
105
+ }}
69
106
  className="inline-block rounded-full object-cover shrink-0"
70
107
  style={{ width: size, height: size }}
71
108
  />
@@ -73,6 +110,7 @@ export default function ContactAvatar({ displayName, size = 32, photoUrl, email
73
110
  }
74
111
  return (
75
112
  <span
113
+ ref={placeholder}
76
114
  aria-hidden="true"
77
115
  style={{ width: size, height: size, backgroundColor: colorOf(displayName), fontSize: size * 0.4 }}
78
116
  className="inline-flex items-center justify-center rounded-full text-white font-semibold shrink-0"
@@ -0,0 +1,26 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useCallback, useState } from "react";
6
+ import type { Contact } from "../../contacts/contactsApi.js";
7
+ import { useContactPhoto } from "../../contacts/useContactPhotoSrc.js";
8
+ import { useApiClient } from "../../util/apiClientContext.js";
9
+ import ContactAvatar, { type ContactAvatarProps } from "./ContactAvatar.js";
10
+
11
+ export interface ContactPhotoAvatarProps extends Omit<ContactAvatarProps, "photoUrl"> {
12
+ /** The stored contact whose own picture is shown, if any. */
13
+ contact?: Pick<Contact, "uid" | "version" | "photoBlobKey">;
14
+ /** For a row of a long list: a picture that has to be fetched (a token-authenticated app) is only fetched once the avatar scrolls into view. */
15
+ lazy?: boolean;
16
+ }
17
+
18
+ /** A `ContactAvatar` showing a stored contact's own picture the way the current app can load it - see `useContactPhotoSrc()`. */
19
+ export default function ContactPhotoAvatar({ contact, lazy, ...rest }: ContactPhotoAvatarProps) {
20
+ // Without IntersectionObserver there is no telling what is on screen, so the picture is fetched at once.
21
+ const [visible, setVisible] = useState(() => !lazy || typeof IntersectionObserver === "undefined");
22
+ const show = useCallback(() => setVisible(true), []);
23
+ const { src, failed } = useContactPhoto(contact, useApiClient(), visible);
24
+ // A picture that exists but is not here yet - not fetched yet or on its way - keeps Gravatar from being asked; one that could not be fetched does not.
25
+ return <ContactAvatar {...rest} photoUrl={src} photoPending={!!contact?.photoBlobKey && !src && !failed} onVisible={visible ? undefined : show} />;
26
+ }
@@ -0,0 +1,133 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useEffect, useRef, useState } from "react";
6
+ import Button from "../buttons/Button.js";
7
+ import Alert from "../feedback/Alert.js";
8
+ import Modal from "../overlays/Modal.js";
9
+
10
+ export interface CameraCaptureModalProps {
11
+ /** Called with the captured picture (a JPEG of the frame on screen); the camera is already released. The caller closes the dialog. */
12
+ onCapture: (file: File) => void;
13
+ onClose: () => void;
14
+ /** Also told why the camera could not be used or the picture not captured (the dialog shows it itself too). */
15
+ onError?: (message: string) => void;
16
+ }
17
+
18
+ export const CAMERA_BLOCKED_MESSAGE = "Camera access was blocked - allow it in your browser's settings, or choose Upload file.";
19
+ export const CAMERA_MISSING_MESSAGE = "No camera was found on this device - choose Upload file instead.";
20
+ export const CAMERA_FAILED_MESSAGE = "The camera could not be started - choose Upload file instead.";
21
+
22
+ /** The friendly reason a `getUserMedia()` rejection gives (`NotAllowedError`/`SecurityError`: the user or the page's policy said no; `NotFoundError`/`OverconstrainedError`: no camera). */
23
+ function cameraErrorMessage(err: unknown): string {
24
+ const name = (err as { name?: string } | null)?.name;
25
+ if (name === "NotAllowedError" || name === "SecurityError") {
26
+ return CAMERA_BLOCKED_MESSAGE;
27
+ }
28
+ if (name === "NotFoundError" || name === "OverconstrainedError" || name === "DevicesNotFoundError") {
29
+ return CAMERA_MISSING_MESSAGE;
30
+ }
31
+ return CAMERA_FAILED_MESSAGE;
32
+ }
33
+
34
+ /**
35
+ * A dialog with the live picture of the device's camera (the front one where there is a choice) and a Capture button, for a computer with a webcam - a phone's own
36
+ * camera is reached through a file input's `capture` attribute instead. Asking for the camera is what makes the browser prompt for permission; every track is
37
+ * stopped again when the dialog goes away (captured, cancelled, or the page left), which is what turns the camera's light off.
38
+ */
39
+ export default function CameraCaptureModal({ onCapture, onClose, onError }: CameraCaptureModalProps) {
40
+ const video = useRef<HTMLVideoElement>(null);
41
+ const stream = useRef<MediaStream | null>(null);
42
+ // Cleared when the dialog is cancelled or gone: a frame still being encoded then must not reach `onCapture` of a caller that has moved on.
43
+ const open = useRef(true);
44
+ const [ready, setReady] = useState(false);
45
+ const [error, setError] = useState<string | null>(null);
46
+ // From pressing Capture until the frame is encoded: a second press then would hand the caller a second picture.
47
+ const [capturing, setCapturing] = useState(false);
48
+
49
+ function fail(message: string) {
50
+ setError(message);
51
+ onError?.(message);
52
+ }
53
+
54
+ function stop() {
55
+ stream.current?.getTracks().forEach((track) => track.stop());
56
+ stream.current = null;
57
+ }
58
+
59
+ useEffect(() => {
60
+ let cancelled = false;
61
+ navigator.mediaDevices.getUserMedia({ video: { facingMode: "user", width: { ideal: 1280 }, height: { ideal: 1280 } } }).then(
62
+ (media) => {
63
+ if (cancelled) {
64
+ media.getTracks().forEach((track) => track.stop());
65
+ return;
66
+ }
67
+ stream.current = media;
68
+ video.current!.srcObject = media;
69
+ },
70
+ (err) => !cancelled && fail(cameraErrorMessage(err)),
71
+ );
72
+ return () => {
73
+ cancelled = true;
74
+ open.current = false;
75
+ stop();
76
+ };
77
+ }, []);
78
+
79
+ function close() {
80
+ open.current = false;
81
+ stop();
82
+ onClose();
83
+ }
84
+
85
+ function capture() {
86
+ setCapturing(true);
87
+ const element = video.current!;
88
+ const canvas = document.createElement("canvas");
89
+ canvas.width = element.videoWidth;
90
+ canvas.height = element.videoHeight;
91
+ canvas.getContext("2d")!.drawImage(element, 0, 0, canvas.width, canvas.height);
92
+ canvas.toBlob(
93
+ (blob) => {
94
+ if (!open.current) {
95
+ return;
96
+ }
97
+ if (!blob) {
98
+ setCapturing(false);
99
+ fail("The picture could not be captured - try again.");
100
+ return;
101
+ }
102
+ stop();
103
+ onCapture(new File([blob], "photo.jpg", { type: "image/jpeg" }));
104
+ },
105
+ "image/jpeg",
106
+ 0.92,
107
+ );
108
+ }
109
+
110
+ return (
111
+ <Modal open onClose={close} title="Take photo">
112
+ {error && <Alert>{error}</Alert>}
113
+ {/* Mirrored like a mirror, which is what looking at yourself expects; the picture that is kept is not. */}
114
+ <video
115
+ ref={video}
116
+ autoPlay
117
+ playsInline
118
+ muted
119
+ aria-label="Camera preview"
120
+ onLoadedMetadata={() => setReady(true)}
121
+ className={`w-full aspect-square object-cover rounded-sm bg-black -scale-x-100 ${error ? "hidden" : ""}`}
122
+ />
123
+ <div className="mt-5 flex justify-end gap-2">
124
+ <Button type="button" variant="secondary" className="!w-auto" onClick={close}>
125
+ Cancel
126
+ </Button>
127
+ <Button type="button" className="!w-auto" disabled={!ready || !!error || capturing} onClick={capture}>
128
+ Capture
129
+ </Button>
130
+ </div>
131
+ </Modal>
132
+ );
133
+ }