@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
@@ -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> {
@@ -3,21 +3,63 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
 
6
- /** The `localStorage` key of the privacy preference: `"off"` stops contact avatars from asking Gravatar for a picture. */
6
+ /** The `localStorage` key of the privacy preference: `"on"` lets contact avatars ask Gravatar for a picture; anything else (including unset or an old `"off"`) does not. */
7
7
  export const GRAVATAR_PREFERENCE_KEY = "rapidmx:gravatar";
8
8
 
9
+ /** Fired on `window` when this tab changes the preference (the `storage` event covers other tabs). */
10
+ export const GRAVATAR_PREFERENCE_EVENT = "rapidmx:gravatar-changed";
11
+
9
12
  /**
10
- * Whether avatars may look a person up at Gravatar by the SHA-256 hash of their address (never the address itself). On unless the reader turned
11
- * it off (`localStorage["rapidmx:gravatar"] = "off"`); a browser without storage keeps the default.
13
+ * Whether avatars may look a person up at Gravatar by the SHA-256 hash of their address (never the address itself). Off unless the reader turned it on
14
+ * in Settings > Privacy (`localStorage["rapidmx:gravatar"] = "on"`): it tells gravatar.com the hash and the reader's IP address. A browser without
15
+ * storage stays off.
12
16
  */
13
17
  export function gravatarEnabled(): boolean {
14
18
  try {
15
- return globalThis.localStorage?.getItem(GRAVATAR_PREFERENCE_KEY) !== "off";
19
+ return globalThis.localStorage.getItem(GRAVATAR_PREFERENCE_KEY) === "on";
16
20
  } catch {
17
- return true;
21
+ return false;
18
22
  }
19
23
  }
20
24
 
25
+ /** Turns the Gravatar lookup on or off and tells every subscriber (`subscribeGravatarPreference()`) in this tab. */
26
+ export function setGravatarEnabled(enabled: boolean): void {
27
+ try {
28
+ globalThis.localStorage.setItem(GRAVATAR_PREFERENCE_KEY, enabled ? "on" : "off");
29
+ } catch {
30
+ // Storage is unavailable: the preference cannot be kept, so it stays off.
31
+ }
32
+ globalThis.dispatchEvent(new Event(GRAVATAR_PREFERENCE_EVENT));
33
+ }
34
+
35
+ /** Calls `listener` whenever the preference changes in this tab or another; returns the unsubscribe function. Shaped for `useSyncExternalStore`. */
36
+ export function subscribeGravatarPreference(listener: () => void): () => void {
37
+ const onStorage = (event: StorageEvent) => {
38
+ if (event.key === null || event.key === GRAVATAR_PREFERENCE_KEY) {
39
+ listener();
40
+ }
41
+ };
42
+ globalThis.addEventListener(GRAVATAR_PREFERENCE_EVENT, listener);
43
+ globalThis.addEventListener("storage", onStorage);
44
+ return () => {
45
+ globalThis.removeEventListener(GRAVATAR_PREFERENCE_EVENT, listener);
46
+ globalThis.removeEventListener("storage", onStorage);
47
+ };
48
+ }
49
+
50
+ /** Gravatar URLs that answered with nothing, so a list that shows the same person again does not ask again for the page's lifetime. */
51
+ const missing = new Set<string>();
52
+
53
+ /** Remembers that Gravatar has no picture at `url`. */
54
+ export function markGravatarMissing(url: string): void {
55
+ missing.add(url);
56
+ }
57
+
58
+ /** Whether Gravatar already answered with no picture at `url`. */
59
+ export function isGravatarMissing(url: string): boolean {
60
+ return missing.has(url);
61
+ }
62
+
21
63
  const hashes = new Map<string, Promise<string | undefined>>();
22
64
 
23
65
  /** The lowercase-hex SHA-256 of a trimmed, lowercased `email`, or `undefined` where the browser has no Web Crypto (an insecure page). */
@@ -0,0 +1,273 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Gets a picture the user chose - a phone's camera photo is typically 2-8 MB, may be HEIC and carries an EXIF orientation - into what the server takes for a
7
+ * contact's picture (JPEG, PNG, GIF or WebP, at most `CONTACT_PHOTO_MAX_BYTES`, checked by its magic bytes), in the browser, before it is uploaded.
8
+ */
9
+ import { CONTACT_PHOTO_MAX_BYTES, CONTACT_PHOTO_TYPES } from "./contactsApi.js";
10
+
11
+ /** The longest side (in pixels) an accepted picture may have and still be sent as it is. */
12
+ export const CONTACT_PHOTO_MAX_SIDE = 1024;
13
+ /** The side (in pixels) of the square picture made from one that has to be changed - an avatar is shown at 72 pixels at most, 512 covers a high-density screen. */
14
+ export const CONTACT_PHOTO_OUTPUT_SIDE = 512;
15
+ /** JPEG qualities tried in turn until the picture fits `CONTACT_PHOTO_MAX_BYTES`. */
16
+ export const CONTACT_PHOTO_QUALITIES = [0.85, 0.75, 0.65, 0.55, 0.45, 0.35];
17
+
18
+ /** The most pixels (width x height) a picture may have to be decoded: a few kilobytes of PNG can say 30000 x 30000, which takes gigabytes of memory to decode. */
19
+ export const CONTACT_PHOTO_MAX_PIXELS = 16_000_000;
20
+ /** How much of the start of a picture is read to find its size: a JPEG's EXIF segment (up to 64 KB) comes before the size. */
21
+ const HEADER_BYTES = 128 * 1024;
22
+
23
+ export const CONTACT_PHOTO_UNSUPPORTED_MESSAGE = "This picture's format isn't supported by your browser — choose a JPEG or PNG instead.";
24
+ export const CONTACT_PHOTO_TOO_LARGE_MESSAGE = "This picture could not be made small enough — choose another one.";
25
+
26
+ /** Why a chosen picture cannot be used (a format the browser cannot decode, such as HEIC, or one that stays too large); its `message` is fit to show the user. */
27
+ export class ContactPhotoError extends Error {
28
+ constructor(message: string = CONTACT_PHOTO_UNSUPPORTED_MESSAGE) {
29
+ super(message);
30
+ this.name = "ContactPhotoError";
31
+ }
32
+ }
33
+
34
+ /** How many JPEG segments are read, one small read each, past the start of a picture before giving up on finding its size. */
35
+ const MAX_JPEG_SEGMENTS = 4096;
36
+
37
+ /** What a JPEG segment starting at `view[0]` (`view` holds its first 9 bytes, fewer at the end of the file) says: how far to its
38
+ * next segment (`skip`), its size when it is the first frame header ("SOFn"), or `undefined` when there is nothing more to read -
39
+ * not a marker, the start of the scan, or a cut-off header. */
40
+ function jpegSegment(view: Uint8Array): { skip: number } | { width: number; height: number } | undefined {
41
+ const be16 = (offset: number) => (view[offset] << 8) | view[offset + 1];
42
+ if (view.length < 4 || view[0] !== 0xff) {
43
+ return undefined;
44
+ }
45
+ const marker = view[1];
46
+ if (marker === 0xff) {
47
+ return { skip: 1 };
48
+ }
49
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
50
+ return { skip: 2 };
51
+ }
52
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
53
+ return view.length >= 9 ? { width: be16(7), height: be16(5) } : undefined;
54
+ }
55
+ return marker === 0xda ? undefined : { skip: 2 + be16(2) };
56
+ }
57
+
58
+ /** The size of the JPEG `file`, read segment by segment from `offset` (where its start ran out) - an ICC profile or several EXIF/XMP
59
+ * segments can put the frame header well past the start. */
60
+ async function jpegSizeFrom(file: Blob, offset: number): Promise<{ width: number; height: number } | undefined> {
61
+ for (let count = 0; count < MAX_JPEG_SEGMENTS && offset < file.size; count++) {
62
+ const segment = jpegSegment(new Uint8Array(await file.slice(offset, offset + 9).arrayBuffer()));
63
+ if (!segment) {
64
+ return undefined;
65
+ }
66
+ if ("width" in segment) {
67
+ return segment;
68
+ }
69
+ offset += segment.skip;
70
+ }
71
+ return undefined;
72
+ }
73
+
74
+ /** What the start of a picture says about it: whether it is a JPEG, its size when that can be read from it and, for a JPEG whose
75
+ * start ran out before its frame header, where to read on from (`resume`). */
76
+ function inspect(head: Uint8Array): { jpeg: boolean; width?: number; height?: number; resume?: number } {
77
+ const at = (offset: number, text: string) => Array.from(text).every((char, i) => head[offset + i] === char.charCodeAt(0));
78
+ const be16 = (offset: number) => (head[offset] << 8) | head[offset + 1];
79
+ const be32 = (offset: number) => be16(offset) * 65536 + be16(offset + 2);
80
+ const le16 = (offset: number) => head[offset] | (head[offset + 1] << 8);
81
+ const le24 = (offset: number) => le16(offset) | (head[offset + 2] << 16);
82
+ const known = (width: number, height: number) => ({ jpeg: false, width, height });
83
+ if (at(0, "\x89PNG\r\n\x1a\n") && head.length >= 24) {
84
+ return known(be32(16), be32(20));
85
+ }
86
+ if (at(0, "GIF8") && head.length >= 10) {
87
+ return known(le16(6), le16(8));
88
+ }
89
+ if (at(0, "RIFF") && at(8, "WEBP") && head.length >= 30) {
90
+ if (at(12, "VP8 ")) {
91
+ return known(le16(26) & 0x3fff, le16(28) & 0x3fff);
92
+ }
93
+ if (at(12, "VP8L")) {
94
+ const bits = (le16(21) | (le16(23) << 16)) >>> 0;
95
+ return known((bits & 0x3fff) + 1, ((bits >>> 14) & 0x3fff) + 1);
96
+ }
97
+ if (at(12, "VP8X")) {
98
+ return known(le24(24) + 1, le24(27) + 1);
99
+ }
100
+ return { jpeg: false };
101
+ }
102
+ if (!(head[0] === 0xff && head[1] === 0xd8)) {
103
+ return { jpeg: false };
104
+ }
105
+ // A JPEG is a run of segments (`FF marker`, then a length unless the marker stands alone); the size is in the first frame header, "SOFn".
106
+ let offset = 2;
107
+ while (offset + 4 <= head.length) {
108
+ const segment = jpegSegment(head.subarray(offset, offset + 9));
109
+ if (!segment) {
110
+ return { jpeg: true };
111
+ }
112
+ if ("width" in segment) {
113
+ return { jpeg: true, ...segment };
114
+ }
115
+ offset += segment.skip;
116
+ }
117
+ return { jpeg: true, resume: offset };
118
+ }
119
+
120
+ interface Decoded {
121
+ source: CanvasImageSource;
122
+ width: number;
123
+ height: number;
124
+ /** Frees what holds the decoded pixels. */
125
+ release: () => void;
126
+ }
127
+
128
+ /** Decodes with the orientation of the photo's EXIF applied, so a phone's sideways-stored picture comes out upright. */
129
+ async function decode(file: Blob): Promise<Decoded> {
130
+ if (typeof createImageBitmap === "function") {
131
+ try {
132
+ const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
133
+ return { source: bitmap, width: bitmap.width, height: bitmap.height, release: () => bitmap.close() };
134
+ } catch {
135
+ // Fall through: an `<img>` may still read what this `createImageBitmap()` would not.
136
+ }
137
+ }
138
+ // An `<img>` applies the EXIF orientation itself (`image-orientation: from-image` is the default).
139
+ const url = URL.createObjectURL(file);
140
+ try {
141
+ const image = await new Promise<HTMLImageElement>((resolve, reject) => {
142
+ const element = new Image();
143
+ element.onload = () => resolve(element);
144
+ element.onerror = () => reject(new ContactPhotoError());
145
+ element.src = url;
146
+ });
147
+ return { source: image, width: image.naturalWidth, height: image.naturalHeight, release: () => undefined };
148
+ } finally {
149
+ URL.revokeObjectURL(url);
150
+ }
151
+ }
152
+
153
+ interface Surface {
154
+ context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
155
+ encode: (type: string, quality?: number) => Promise<Blob | null>;
156
+ }
157
+
158
+ function createSurface(side: number): Surface {
159
+ if (typeof OffscreenCanvas === "function") {
160
+ const canvas = new OffscreenCanvas(side, side);
161
+ const context = canvas.getContext("2d");
162
+ if (!context) {
163
+ throw new ContactPhotoError();
164
+ }
165
+ return { context, encode: (type, quality) => canvas.convertToBlob({ type, quality }) };
166
+ }
167
+ const canvas = document.createElement("canvas");
168
+ canvas.width = side;
169
+ canvas.height = side;
170
+ const context = canvas.getContext("2d");
171
+ if (!context) {
172
+ throw new ContactPhotoError();
173
+ }
174
+ return { context, encode: (type, quality) => new Promise((resolve) => canvas.toBlob(resolve, type, quality)) };
175
+ }
176
+
177
+ function hasTransparency(context: Surface["context"], side: number): boolean {
178
+ const { data } = context.getImageData(0, 0, side, side);
179
+ for (let i = 3; i < data.length; i += 4) {
180
+ if (data[i] < 255) {
181
+ return true;
182
+ }
183
+ }
184
+ return false;
185
+ }
186
+
187
+ function renamed(name: string, extension: "jpg" | "png" | "webp"): string {
188
+ return `${name.replace(/\.[^./\\]*$/, "") || "photo"}.${extension}`;
189
+ }
190
+
191
+ /**
192
+ * The picture to upload for `file`. A picture the server already takes (an accepted type within the byte limit and at most `CONTACT_PHOTO_MAX_SIDE` pixels a
193
+ * side) is returned as it is - an animated GIF stays animated - unless it is a JPEG, which is always drawn again to drop its EXIF. Anything else (a phone's 6 MB photo, a HEIC, a huge PNG) is decoded with its EXIF orientation applied,
194
+ * cropped to the centered square - avatars are circular, so the corners are never seen - scaled down to at most `CONTACT_PHOTO_OUTPUT_SIDE` pixels and encoded as a
195
+ * JPEG (transparent parts on white, quality lowered step by step until it fits), or kept as PNG/WebP when it has transparency and still fits.
196
+ *
197
+ * @throws ContactPhotoError when the browser cannot decode it (HEIC, or not a picture at all), it says it has more than `CONTACT_PHOTO_MAX_PIXELS` pixels
198
+ * (it is not decoded at all then) or it cannot be made small enough.
199
+ */
200
+ export async function prepareContactPhoto(file: File): Promise<File> {
201
+ const accepted = CONTACT_PHOTO_TYPES.includes(file.type) && file.size <= CONTACT_PHOTO_MAX_BYTES;
202
+ let head = inspect(new Uint8Array(await file.slice(0, HEADER_BYTES).arrayBuffer()));
203
+ if (head.resume !== undefined) {
204
+ head = { ...head, ...(await jpegSizeFrom(file, head.resume)) };
205
+ }
206
+ if (head.jpeg && (!head.width || !head.height)) {
207
+ // A JPEG whose size is not found within the segments read (or is given as zero, to be redefined by a later marker) could be of any
208
+ // size, and would reach the decoder unchecked.
209
+ throw new ContactPhotoError();
210
+ }
211
+ if (head.width !== undefined && head.height !== undefined && head.width * head.height > CONTACT_PHOTO_MAX_PIXELS) {
212
+ throw new ContactPhotoError(CONTACT_PHOTO_TOO_LARGE_MESSAGE);
213
+ }
214
+ // A JPEG is always drawn again: its EXIF (the camera, the time, often where it was taken) would otherwise be uploaded and shown to whoever sees the contact.
215
+ const jpeg = head.jpeg || file.type === "image/jpeg";
216
+ let decoded: Decoded;
217
+ try {
218
+ decoded = await decode(file);
219
+ } catch (err) {
220
+ if (accepted && !jpeg) {
221
+ // It cannot be measured here but the server takes it as it is.
222
+ return file;
223
+ }
224
+ throw err;
225
+ }
226
+ try {
227
+ const { source, width, height } = decoded;
228
+ // The size could not be read from the file itself (a format with no reader above, or a size past the segments read): what
229
+ // the decoder made of it is the last check before it is drawn.
230
+ if (width * height > CONTACT_PHOTO_MAX_PIXELS) {
231
+ throw new ContactPhotoError(CONTACT_PHOTO_TOO_LARGE_MESSAGE);
232
+ }
233
+ if (accepted && !jpeg && Math.max(width, height) <= CONTACT_PHOTO_MAX_SIDE) {
234
+ return file;
235
+ }
236
+ const crop = Math.min(width, height);
237
+ // Never enlarged: a small picture only gets cropped.
238
+ const side = Math.min(crop, CONTACT_PHOTO_OUTPUT_SIDE);
239
+ const { context, encode } = createSurface(side);
240
+ const draw = () => {
241
+ context.imageSmoothingQuality = "high";
242
+ context.drawImage(source, (width - crop) / 2, (height - crop) / 2, crop, crop, 0, 0, side, side);
243
+ };
244
+
245
+ draw();
246
+ if (["image/png", "image/webp", "image/gif"].includes(file.type) && hasTransparency(context, side)) {
247
+ const type = file.type === "image/webp" ? "image/webp" : "image/png";
248
+ const blob = await encode(type);
249
+ if (blob && blob.size <= CONTACT_PHOTO_MAX_BYTES) {
250
+ // A browser that cannot encode WebP answers with a PNG instead.
251
+ const kept = blob.type || type;
252
+ return new File([blob], renamed(file.name, kept === "image/webp" ? "webp" : "png"), { type: kept });
253
+ }
254
+ // Too large to keep the transparency: a JPEG has none, so what showed through becomes white.
255
+ context.clearRect(0, 0, side, side);
256
+ }
257
+ context.fillStyle = "#ffffff";
258
+ context.fillRect(0, 0, side, side);
259
+ draw();
260
+ for (const quality of CONTACT_PHOTO_QUALITIES) {
261
+ const blob = await encode("image/jpeg", quality);
262
+ if (!blob) {
263
+ throw new ContactPhotoError();
264
+ }
265
+ if (blob.size <= CONTACT_PHOTO_MAX_BYTES) {
266
+ return new File([blob], renamed(file.name, "jpg"), { type: "image/jpeg" });
267
+ }
268
+ }
269
+ throw new ContactPhotoError(CONTACT_PHOTO_TOO_LARGE_MESSAGE);
270
+ } finally {
271
+ decoded.release();
272
+ }
273
+ }
@@ -0,0 +1,177 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useEffect, useState } from "react";
6
+ import type { ApiClient } from "../util/api.js";
7
+ import { type Contact, contactPhotoUrl, fetchContactPhoto } from "./contactsApi.js";
8
+
9
+ /** How many pictures stay loaded (as `blob:` URLs) after nothing shows them any more, so a list scrolled back over does not fetch them all again. */
10
+ const CACHE_LIMIT = 50;
11
+
12
+ interface Entry {
13
+ url: string;
14
+ /** How many mounted avatars show this URL; it is never revoked while that is more than none. */
15
+ refs: number;
16
+ }
17
+
18
+ /** Least recently used first (a `Map` iterates in insertion order, and a use moves the entry to the end). */
19
+ const entries = new Map<string, Entry>();
20
+ /** Fetches in flight, and how many avatars are waiting on each (they are all counted as showing the picture the moment it is stored). */
21
+ const pending = new Map<string, { request: Promise<string>; waiters: number }>();
22
+ const clientIds = new WeakMap<ApiClient, number>();
23
+ let nextClientId = 0;
24
+
25
+ /** One key per picture of one account: the same uid never collides across the clients of a multi-account app, and a replaced picture (new version) is a new key. */
26
+ function keyOf(client: ApiClient, uid: string, version: number): string {
27
+ let id = clientIds.get(client);
28
+ if (id === undefined) {
29
+ id = nextClientId++;
30
+ clientIds.set(client, id);
31
+ }
32
+ return `${id}:${uid}:${version}`;
33
+ }
34
+
35
+ /** Revokes the pictures beyond the limit that nothing shows, oldest first. */
36
+ function trim(): void {
37
+ for (const [key, entry] of entries) {
38
+ if (entries.size <= CACHE_LIMIT) {
39
+ return;
40
+ }
41
+ if (entry.refs === 0) {
42
+ URL.revokeObjectURL(entry.url);
43
+ entries.delete(key);
44
+ }
45
+ }
46
+ }
47
+
48
+ /** How many pictures are fetched at once; a list of a hundred contacts must not open a hundred requests together. */
49
+ const MAX_CONCURRENT_FETCHES = 4;
50
+ let fetching = 0;
51
+ const waiting: (() => void)[] = [];
52
+
53
+ /** Runs `task` once fewer than `MAX_CONCURRENT_FETCHES` others are running, in the order asked. */
54
+ function limited<T>(task: () => Promise<T>): Promise<T> {
55
+ return new Promise<T>((resolve, reject) => {
56
+ const run = () => {
57
+ task()
58
+ .then(resolve, reject)
59
+ .finally(() => {
60
+ // A waiting task takes over the slot; it is only given up when none waits.
61
+ const next = waiting.shift();
62
+ if (next) {
63
+ next();
64
+ } else {
65
+ fetching--;
66
+ }
67
+ });
68
+ };
69
+ if (fetching < MAX_CONCURRENT_FETCHES) {
70
+ fetching++;
71
+ run();
72
+ } else {
73
+ waiting.push(run);
74
+ }
75
+ });
76
+ }
77
+
78
+ /** The `blob:` URL for `key`, fetched with `load` unless it is cached, and counted as shown until `release()`. Concurrent callers share one fetch. */
79
+ async function acquire(key: string, load: () => Promise<Blob>): Promise<string> {
80
+ const entry = entries.get(key);
81
+ if (entry) {
82
+ entry.refs++;
83
+ entries.delete(key);
84
+ entries.set(key, entry);
85
+ return entry.url;
86
+ }
87
+ let shared = pending.get(key);
88
+ if (!shared) {
89
+ const created: { request: Promise<string>; waiters: number } = {
90
+ waiters: 0,
91
+ request: limited(load)
92
+ .then((blob) => {
93
+ const url = URL.createObjectURL(blob);
94
+ // Claimed by everyone waiting from the start: an entry nobody holds yet could be trimmed before its avatar got to it.
95
+ entries.set(key, { url, refs: created.waiters });
96
+ return url;
97
+ })
98
+ .finally(() => pending.delete(key)),
99
+ };
100
+ pending.set(key, created);
101
+ shared = created;
102
+ }
103
+ shared.waiters++;
104
+ return shared.request;
105
+ }
106
+
107
+ function release(key: string): void {
108
+ const entry = entries.get(key);
109
+ if (entry) {
110
+ entry.refs--;
111
+ }
112
+ trim();
113
+ }
114
+
115
+ /** Forgets every cached picture (and revokes its URL). For a test, or when an account is signed out. */
116
+ export function clearContactPhotoCache(): void {
117
+ for (const entry of entries.values()) {
118
+ URL.revokeObjectURL(entry.url);
119
+ }
120
+ entries.clear();
121
+ }
122
+
123
+ /**
124
+ * What to put in an `<img src>` for a contact's own picture, or `undefined` when they have none, it is still loading or it could not be loaded (the avatar
125
+ * then shows initials). Without an explicit `client` that is the plain `contactPhotoUrl()`: the browser sends the `jwt` cookie with the request. A
126
+ * token-authenticated client (the desktop and mobile apps) cannot do that - an `<img>` sends no `Authorization` header - so the picture is fetched through the client
127
+ * and shown as a `blob:` URL, cached per client, contact and version with a small LRU and revoked once it falls out of it and nothing shows it.
128
+ */
129
+ export function useContactPhotoSrc(contact: Pick<Contact, "uid" | "version" | "photoBlobKey"> | undefined, client?: ApiClient): string | undefined {
130
+ return useContactPhoto(contact, client).src;
131
+ }
132
+
133
+ /**
134
+ * `useContactPhotoSrc()`, also saying whether a picture that has to be fetched could not be (`failed`: no `src` is coming), and held back until `active` - an avatar
135
+ * that is not on screen yet has no reason to fetch its picture.
136
+ */
137
+ export function useContactPhoto(
138
+ contact: Pick<Contact, "uid" | "version" | "photoBlobKey"> | undefined,
139
+ client?: ApiClient,
140
+ active = true,
141
+ ): { src: string | undefined; failed: boolean } {
142
+ const [loaded, setLoaded] = useState<{ key: string; url: string } | undefined>();
143
+ const [failedKey, setFailedKey] = useState<string | undefined>();
144
+ const key = active && client && contact?.photoBlobKey ? keyOf(client, contact.uid, contact.version) : undefined;
145
+ const uid = contact?.uid;
146
+ const version = contact?.version;
147
+
148
+ useEffect(() => {
149
+ if (!key || !client) {
150
+ return;
151
+ }
152
+ let cancelled = false;
153
+ let held = false;
154
+ acquire(key, () => fetchContactPhoto(uid!, version!, client)).then(
155
+ (url) => {
156
+ if (cancelled) {
157
+ release(key);
158
+ } else {
159
+ held = true;
160
+ setLoaded({ key, url });
161
+ }
162
+ },
163
+ () => !cancelled && setFailedKey(key),
164
+ );
165
+ return () => {
166
+ cancelled = true;
167
+ if (held) {
168
+ release(key);
169
+ }
170
+ };
171
+ }, [key]);
172
+
173
+ if (client) {
174
+ return { src: key !== undefined && loaded?.key === key ? loaded.url : undefined, failed: key !== undefined && failedKey === key };
175
+ }
176
+ return { src: contact && contactPhotoUrl(contact), failed: false };
177
+ }