@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
@@ -4,9 +4,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useContext, useEffect, useRef, useState } from "react";
7
- import { contactPhotoUrl, setContactFavorite } from "../../../../lib/contacts/contactsApi.js";
7
+ import { setContactFavorite } from "../../../../lib/contacts/contactsApi.js";
8
8
  import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
9
- import ContactAvatar from "../../../../lib/components/avatar/ContactAvatar.js";
9
+ import ContactPhotoAvatar from "../../../../lib/components/avatar/ContactPhotoAvatar.js";
10
+ import ContactPhotoEditor from "./ContactPhotoEditor.js";
10
11
  import Button from "../../../../lib/components/buttons/Button.js";
11
12
  import CopyIconButton from "../../../../lib/components/buttons/CopyIconButton.js";
12
13
  import { HiOutlineEnvelope, HiOutlineUserPlus } from "react-icons/hi2";
@@ -17,6 +18,7 @@ import { formatMailAddress } from "../../../../lib/mail/mailAddress.js";
17
18
  import { listMailboxes } from "../../../../lib/mail/mailApi.js";
18
19
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
19
20
  import { MAILBOX_LIST_LIMIT, MailConnectionContext } from "../../mail/useMailConnection.js";
21
+ import { useMailboxUpdateAccess } from "../../mail/useMailboxUpdateAccess.js";
20
22
  import { notify } from "../../notifications/store.js";
21
23
  import { notifyApiError } from "../../notifications/apiErrors.js";
22
24
  import { APP_HREFS } from "../../navigation/appHrefs.js";
@@ -102,6 +104,8 @@ export default function ContactCard({ participant, context = {}, onClose, userUi
102
104
  const card = useSection(() => loadParticipantCard(participant, context, client));
103
105
  const name = participantName(participant);
104
106
  const stored = changed ?? created ?? (contact.status === "ready" ? contact.data : undefined);
107
+ // A contact in a mailbox shared view-only cannot get a new picture here (the server would refuse it).
108
+ const canEditPhoto = useMailboxUpdateAccess(mailboxes.status === "ready" ? mailboxes.data.find((mailbox) => mailbox.uid === stored?.mailboxUid) : undefined);
105
109
  const external = mailboxes.status === "ready" && isExternalAddress(participant.address, ownDomains(mailboxes.data));
106
110
  // What is known when there is no contact: the name, the address and the message's vCard.
107
111
  const known = stored ?? contactInputFor(participant, card.status === "ready" ? card.data : undefined, { mailboxUid: "", folderUid: "" });
@@ -129,6 +133,8 @@ export default function ContactCard({ participant, context = {}, onClose, userUi
129
133
  }
130
134
  }
131
135
  }
136
+ // Not while a picture is being changed: that goes against the version the star would replace.
137
+ const [photoBusy, setPhotoBusy] = useState(false);
132
138
  async function handleToggleFavorite(current) {
133
139
  try {
134
140
  const next = await setContactFavorite(current, !current.favorite, client);
@@ -144,5 +150,5 @@ export default function ContactCard({ participant, context = {}, onClose, userUi
144
150
  openCompose({ to: formatMailAddress(participant) });
145
151
  onClose();
146
152
  }
147
- return (_jsxs(Modal, { open: true, onClose: onClose, title: name, children: [_jsxs("div", { className: "flex items-center gap-4", children: [_jsx(ContactAvatar, { displayName: name, size: 72, photoUrl: stored && contactPhotoUrl(stored), email: participant.address }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("p", { className: "text-sm text-text-muted break-words", children: [participant.address, _jsx(CopyIconButton, { value: participant.address, label: "Copy address", className: "ml-1" })] }), external && (_jsx("span", { className: "inline-block mt-1 text-xs font-semibold py-0.5 px-2 rounded-pill bg-warning/15 text-text", children: "External" })), (known.jobTitle || known.company) && (_jsx("p", { className: "mt-1 text-sm break-words", children: [known.jobTitle, known.company].filter(Boolean).join(", ") }))] }), stored && _jsx(FavoriteStarButton, { favorite: !!stored.favorite, onToggle: () => void handleToggleFavorite(stored), className: "shrink-0 self-start" })] }), _jsxs("div", { className: "mt-4 flex flex-wrap items-center gap-2", children: [_jsxs(Button, { type: "button", variant: "secondary", className: `${ACTION_CLASS} inline-flex items-center gap-1.5`, onClick: handleEmail, children: [_jsx(HiOutlineEnvelope, { "aria-hidden": "true", className: "size-4" }), "Email"] }), stored ? (_jsx("a", { href: `${APP_HREFS.contacts}/${encodeURIComponent(stored.uid)}`, onClick: onClose, className: "inline-flex items-center text-xs font-semibold py-1 px-3 rounded-sm border border-border hover:border-accent hover:text-accent-dark focus-visible:outline-2 focus-visible:outline-primary", children: "Open contact" })) : (_jsxs(Button, { type: "button", variant: "secondary", className: `${ACTION_CLASS} inline-flex items-center gap-1.5`, disabled: !createWith || creating, loading: creating, onClick: () => void handleCreate(createWith), children: [_jsx(HiOutlineUserPlus, { "aria-hidden": "true", className: "size-4" }), "Add to contacts"] }))] }), _jsxs("section", { className: "mt-5", children: [_jsx("h3", { className: "text-sm font-bold mb-2", children: "Contact" }), !stored && contact.status === "loading" && _jsx(Skeleton, { width: "w-1/2", className: "mb-2" }), !stored && contact.status === "error" && (_jsx("p", { role: "alert", className: "text-sm text-danger mb-2", children: "Couldn\u2019t check your address book." })), !stored && contact.status === "ready" && _jsx("p", { className: "text-sm text-text-muted mb-2", children: "Not in your address book." }), _jsxs("dl", { className: "flex flex-col gap-1.5", children: [known.emails.map((email) => (_jsxs(Detail, { label: ["Email", kindLabel(email.type)].filter(Boolean).join(" "), children: [email.address, _jsx(CopyIconButton, { value: email.address, label: "Copy email address", className: "ml-1" })] }, `e:${email.address}`))), known.phones.map((phone) => (_jsx(Detail, { label: ["Phone", kindLabel(phone.type)].filter(Boolean).join(" "), children: phone.phoneNumber }, `p:${phone.phoneNumber}`))), known.company && _jsx(Detail, { label: "Company", children: known.company }), known.jobTitle && _jsx(Detail, { label: "Job title", children: known.jobTitle }), known.addresses.map((address, index) => (_jsx(Detail, { label: ["Address", kindLabel(address.type)].filter(Boolean).join(" "), children: [address.street, [address.postalCode, address.city].filter(Boolean).join(" "), address.state, address.country].filter(Boolean).join("\n") }, `a:${index}`))), known.notes && _jsx(Detail, { label: "Notes", children: known.notes })] })] }), _jsx(CardSection, { title: "Recent messages", section: messages, empty: "No recent messages", failure: "Couldn't load recent messages.", isEmpty: (list) => list.length === 0, render: (list) => (_jsx("ul", { className: "flex flex-col", children: list.map((message) => (_jsx("li", { children: _jsxs("a", { href: `/messages/${encodeURIComponent(message.uid)}`, onClick: onClose, className: ROW_LINK_CLASS, children: [_jsxs("span", { className: "flex items-baseline justify-between gap-3 text-sm", children: [_jsx("span", { className: "font-medium truncate", children: message.subject || "(No subject)" }), _jsx("time", { dateTime: message.receivedDate, className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsx("span", { className: "block text-xs text-text-muted truncate", children: message.bodyPreview })] }) }, message.uid))) })) }), _jsx(CardSection, { title: "Upcoming events", section: events, empty: "No upcoming events", failure: "Couldn't load upcoming events.", isEmpty: (list) => list.length === 0, render: (list) => (_jsx("ul", { className: "flex flex-col", children: list.map((event) => (_jsx("li", { children: _jsxs("a", { href: APP_HREFS.calendar, onClick: onClose, className: ROW_LINK_CLASS, children: [_jsx("span", { className: "block text-sm font-medium truncate", children: event.title }), _jsx("span", { className: "block text-xs text-text-muted", children: formatInviteWhen(event) })] }) }, event.occurrenceKey))) })) })] }));
153
+ return (_jsxs(Modal, { open: true, onClose: onClose, title: name, children: [_jsxs("div", { className: "flex items-center gap-4", children: [stored && canEditPhoto ? (_jsx(ContactPhotoEditor, { contact: stored, displayName: name, size: 72, email: participant.address, onChanged: setChanged, onBusyChange: setPhotoBusy })) : (_jsx(ContactPhotoAvatar, { displayName: name, size: 72, contact: stored, email: participant.address })), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("p", { className: "text-sm text-text-muted break-words", children: [participant.address, _jsx(CopyIconButton, { value: participant.address, label: "Copy address", className: "ml-1" })] }), external && (_jsx("span", { className: "inline-block mt-1 text-xs font-semibold py-0.5 px-2 rounded-pill bg-warning/15 text-text", children: "External" })), (known.jobTitle || known.company) && (_jsx("p", { className: "mt-1 text-sm break-words", children: [known.jobTitle, known.company].filter(Boolean).join(", ") }))] }), stored && _jsx(FavoriteStarButton, { favorite: !!stored.favorite, disabled: photoBusy, onToggle: () => void handleToggleFavorite(stored), className: "shrink-0 self-start" })] }), _jsxs("div", { className: "mt-4 flex flex-wrap items-center gap-2", children: [_jsxs(Button, { type: "button", variant: "secondary", className: `${ACTION_CLASS} inline-flex items-center gap-1.5`, onClick: handleEmail, children: [_jsx(HiOutlineEnvelope, { "aria-hidden": "true", className: "size-4" }), "Email"] }), stored ? (_jsx("a", { href: `${APP_HREFS.contacts}/${encodeURIComponent(stored.uid)}`, onClick: onClose, className: "inline-flex items-center text-xs font-semibold py-1 px-3 rounded-sm border border-border hover:border-accent hover:text-accent-dark focus-visible:outline-2 focus-visible:outline-primary", children: "Open contact" })) : (_jsxs(Button, { type: "button", variant: "secondary", className: `${ACTION_CLASS} inline-flex items-center gap-1.5`, disabled: !createWith || creating, loading: creating, onClick: () => void handleCreate(createWith), children: [_jsx(HiOutlineUserPlus, { "aria-hidden": "true", className: "size-4" }), "Add to contacts"] }))] }), _jsxs("section", { className: "mt-5", children: [_jsx("h3", { className: "text-sm font-bold mb-2", children: "Contact" }), !stored && contact.status === "loading" && _jsx(Skeleton, { width: "w-1/2", className: "mb-2" }), !stored && contact.status === "error" && (_jsx("p", { role: "alert", className: "text-sm text-danger mb-2", children: "Couldn\u2019t check your address book." })), !stored && contact.status === "ready" && _jsx("p", { className: "text-sm text-text-muted mb-2", children: "Not in your address book." }), _jsxs("dl", { className: "flex flex-col gap-1.5", children: [known.emails.map((email) => (_jsxs(Detail, { label: ["Email", kindLabel(email.type)].filter(Boolean).join(" "), children: [email.address, _jsx(CopyIconButton, { value: email.address, label: "Copy email address", className: "ml-1" })] }, `e:${email.address}`))), known.phones.map((phone) => (_jsx(Detail, { label: ["Phone", kindLabel(phone.type)].filter(Boolean).join(" "), children: phone.phoneNumber }, `p:${phone.phoneNumber}`))), known.company && _jsx(Detail, { label: "Company", children: known.company }), known.jobTitle && _jsx(Detail, { label: "Job title", children: known.jobTitle }), known.addresses.map((address, index) => (_jsx(Detail, { label: ["Address", kindLabel(address.type)].filter(Boolean).join(" "), children: [address.street, [address.postalCode, address.city].filter(Boolean).join(" "), address.state, address.country].filter(Boolean).join("\n") }, `a:${index}`))), known.notes && _jsx(Detail, { label: "Notes", children: known.notes })] })] }), _jsx(CardSection, { title: "Recent messages", section: messages, empty: "No recent messages", failure: "Couldn't load recent messages.", isEmpty: (list) => list.length === 0, render: (list) => (_jsx("ul", { className: "flex flex-col", children: list.map((message) => (_jsx("li", { children: _jsxs("a", { href: `/messages/${encodeURIComponent(message.uid)}`, onClick: onClose, className: ROW_LINK_CLASS, children: [_jsxs("span", { className: "flex items-baseline justify-between gap-3 text-sm", children: [_jsx("span", { className: "font-medium truncate", children: message.subject || "(No subject)" }), _jsx("time", { dateTime: message.receivedDate, className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsx("span", { className: "block text-xs text-text-muted truncate", children: message.bodyPreview })] }) }, message.uid))) })) }), _jsx(CardSection, { title: "Upcoming events", section: events, empty: "No upcoming events", failure: "Couldn't load upcoming events.", isEmpty: (list) => list.length === 0, render: (list) => (_jsx("ul", { className: "flex flex-col", children: list.map((event) => (_jsx("li", { children: _jsxs("a", { href: APP_HREFS.calendar, onClick: onClose, className: ROW_LINK_CLASS, children: [_jsx("span", { className: "block text-sm font-medium truncate", children: event.title }), _jsx("span", { className: "block text-xs text-text-muted", children: formatInviteWhen(event) })] }) }, event.occurrenceKey))) })) })] }));
148
154
  }
@@ -4,6 +4,8 @@ export interface ContactDetailPaneProps {
4
4
  contact: Contact;
5
5
  onEdit: () => void;
6
6
  onDelete: () => void;
7
+ /** Whether the reader may change this contact: `false` for one in a mailbox shared with them view-only, which gets no Edit or Delete. Default `true`. */
8
+ canWrite?: boolean;
7
9
  /** Present only on the mobile detail route — renders a "back to contacts" link above the header. Absent
8
10
  * on the desktop inline pane, which never navigates away (selecting a different contact just swaps
9
11
  * `contact` in place). */
@@ -13,8 +15,8 @@ export interface ContactDetailPaneProps {
13
15
  onKeysChanged?: () => void;
14
16
  /** Called with the contact after the star was toggled, so the caller shows the changed contact. */
15
17
  onChanged?: (contact: Contact) => void;
16
- /** `false` when the reader is known not to be able to change this contact's keys (the actions are hidden); `undefined`
17
- * when unknown (they're hidden after a 403). */
18
+ /** `false` when the reader is known not to be able to change this contact (its key actions and the photo badge are hidden); `undefined`
19
+ * when unknown (the key actions are hidden after a 403; a refused photo change is a pop-up). */
18
20
  canResolveKeys?: boolean;
19
21
  }
20
22
  /**
@@ -22,4 +24,4 @@ export interface ContactDetailPaneProps {
22
24
  * always visible alongside the contact list) and the mobile detail route
23
25
  * (`apps/www/contacts/[uid].tsx`, a full page on its own reached by tapping a contact row).
24
26
  */
25
- export default function ContactDetailPane({ contact, onEdit, onDelete, backHref, onKeysChanged, onChanged, canResolveKeys }: ContactDetailPaneProps): React.JSX.Element;
27
+ export default function ContactDetailPane({ contact, onEdit, onDelete, canWrite, backHref, onKeysChanged, onChanged, canResolveKeys }: ContactDetailPaneProps): React.JSX.Element;
@@ -1,16 +1,17 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  ///////////////////////////////////////////////////////////////////////////////
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useState } from "react";
7
- import { contactPhotoUrl, setContactFavorite } from "../../../../lib/contacts/contactsApi.js";
7
+ import { setContactFavorite } from "../../../../lib/contacts/contactsApi.js";
8
8
  import CopyIconButton from "../../../../lib/components/buttons/CopyIconButton.js";
9
9
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
10
10
  import { notifyApiError } from "../../notifications/apiErrors.js";
11
11
  import FavoriteStarButton from "./FavoriteStarButton.js";
12
12
  import Button from "../../../../lib/components/buttons/Button.js";
13
- import ContactAvatar from "../../../../lib/components/avatar/ContactAvatar.js";
13
+ import ContactPhotoAvatar from "../../../../lib/components/avatar/ContactPhotoAvatar.js";
14
+ import ContactPhotoEditor from "./ContactPhotoEditor.js";
14
15
  import KeyChangeReview from "./KeyChangeReview.js";
15
16
  import { KEY_CHANGE_STALE_MESSAGE, formatDate, keyPinnedSince, revocationLabel } from "./contactKeys.js";
16
17
  import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
@@ -32,13 +33,18 @@ function RevocationBadge({ label }) {
32
33
  * always visible alongside the contact list) and the mobile detail route
33
34
  * (`apps/www/contacts/[uid].tsx`, a full page on its own reached by tapping a contact row).
34
35
  */
35
- export default function ContactDetailPane({ contact, onEdit, onDelete, backHref, onKeysChanged, onChanged, canResolveKeys }) {
36
+ export default function ContactDetailPane({ contact, onEdit, onDelete, canWrite = true, backHref, onKeysChanged, onChanged, canResolveKeys }) {
36
37
  const client = useApiClient();
37
38
  // The outcome of the last key decision, kept per contact so selecting another contact doesn't carry it over.
38
39
  const [keyNotice, setKeyNotice] = useState(null);
39
40
  const notice = keyNotice?.contactUid === contact.uid ? keyNotice.text : null;
40
41
  const keyConflicts = contact.keyConflicts ?? [];
41
42
  const previousKeys = contact.previousKeys ?? [];
43
+ // Not while a picture is being changed: that goes against the version the star would replace.
44
+ const [photoBusy, setPhotoBusy] = useState(false);
45
+ // Delete asks first - it also drops the pinned signing keys this contact vouches with - and the question belongs to the contact it was asked about.
46
+ const [confirmingDeleteUid, setConfirmingDeleteUid] = useState(null);
47
+ const confirmingDelete = confirmingDeleteUid === contact.uid;
42
48
  async function handleToggleFavorite() {
43
49
  try {
44
50
  const changed = await setContactFavorite(contact, !contact.favorite, client);
@@ -54,7 +60,7 @@ export default function ContactDetailPane({ contact, onEdit, onDelete, backHref,
54
60
  clearPinnedSignerCache();
55
61
  onKeysChanged?.();
56
62
  }
57
- return (_jsxs("div", { role: "region", "aria-label": "Contact details", className: "max-w-xl flex flex-col gap-5", children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline", children: "\u2190 Back to contacts" })), _jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(ContactAvatar, { displayName: contact.displayName, size: 48, photoUrl: contactPhotoUrl(contact), email: contact.emails[0]?.address }), _jsxs("div", { children: [_jsxs("h1", { className: "text-xl font-bold tracking-tight", children: [contact.displayName, _jsx(FavoriteStarButton, { favorite: !!contact.favorite, onToggle: () => void handleToggleFavorite(), className: "ml-1 align-middle" })] }), contact.jobTitle && contact.company && (_jsxs("p", { className: "text-sm text-text-muted mt-0.5", children: [contact.jobTitle, " at ", contact.company] }))] })] }), _jsxs("div", { className: "flex gap-2 shrink-0", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onEdit, children: "Edit" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", onClick: onDelete, children: "Delete" })] })] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6 flex flex-col gap-4 text-sm", children: [contact.emails.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Email" }), contact.emails.map((e, i) => (_jsxs("div", { children: [e.address, _jsx(CopyIconButton, { value: e.address, label: "Copy email address", className: "mx-0.5" }), " ", _jsxs("span", { className: "text-text-muted", children: ["(", e.type, ")"] })] }, i)))] })), contact.phones.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Phone" }), contact.phones.map((p, i) => (_jsxs("div", { children: [p.phoneNumber, " ", _jsxs("span", { className: "text-text-muted", children: ["(", p.type, ")"] })] }, i)))] })), contact.addresses.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Address" }), contact.addresses.map((a, i) => (_jsx("div", { children: [a.street, a.city, a.state, a.postalCode, a.country].filter(Boolean).join(", ") }, i)))] })), contact.categories && contact.categories.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Categories" }), _jsx("div", { children: contact.categories.join(", ") })] })), contact.notes && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Notes" }), _jsx("div", { children: contact.notes })] })), (contact.keys && contact.keys.length > 0) ||
63
+ return (_jsxs("div", { role: "region", "aria-label": "Contact details", className: "max-w-xl flex flex-col gap-5", children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline", children: "\u2190 Back to contacts" })), _jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { className: "flex items-center gap-3", children: [canResolveKeys === false ? (_jsx(ContactPhotoAvatar, { displayName: contact.displayName, size: 48, contact: contact, email: contact.emails[0]?.address })) : (_jsx(ContactPhotoEditor, { contact: contact, displayName: contact.displayName, size: 48, badgeSize: "sm", email: contact.emails[0]?.address, onChanged: (changed) => onChanged?.(changed), onBusyChange: setPhotoBusy })), _jsxs("div", { children: [_jsxs("h1", { className: "text-xl font-bold tracking-tight", children: [contact.displayName, _jsx(FavoriteStarButton, { favorite: !!contact.favorite, disabled: photoBusy, onToggle: () => void handleToggleFavorite(), className: "ml-1 align-middle" })] }), contact.jobTitle && contact.company && (_jsxs("p", { className: "text-sm text-text-muted mt-0.5", children: [contact.jobTitle, " at ", contact.company] }))] })] }), canWrite && (_jsx("div", { className: "flex flex-wrap justify-end gap-2 shrink-0", children: confirmingDelete ? (_jsxs(_Fragment, { children: [_jsx("span", { role: "alert", className: "self-center text-sm", children: "Delete this contact?" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", onClick: onDelete, children: "Yes, delete" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingDeleteUid(null), children: "Keep" })] })) : (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onEdit, children: "Edit" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", onClick: () => setConfirmingDeleteUid(contact.uid), children: "Delete" })] })) }))] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6 flex flex-col gap-4 text-sm", children: [contact.emails.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Email" }), contact.emails.map((e, i) => (_jsxs("div", { children: [e.address, _jsx(CopyIconButton, { value: e.address, label: "Copy email address", className: "mx-0.5" }), " ", _jsxs("span", { className: "text-text-muted", children: ["(", e.type, ")"] })] }, i)))] })), contact.phones.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Phone" }), contact.phones.map((p, i) => (_jsxs("div", { children: [p.phoneNumber, " ", _jsxs("span", { className: "text-text-muted", children: ["(", p.type, ")"] })] }, i)))] })), contact.addresses.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Address" }), contact.addresses.map((a, i) => (_jsx("div", { children: [a.street, a.city, a.state, a.postalCode, a.country].filter(Boolean).join(", ") }, i)))] })), contact.categories && contact.categories.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Categories" }), _jsx("div", { children: contact.categories.join(", ") })] })), contact.notes && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Notes" }), _jsx("div", { children: contact.notes })] })), (contact.keys && contact.keys.length > 0) ||
58
64
  contact.encryptPreference ||
59
65
  keyConflicts.length > 0 ||
60
66
  previousKeys.length > 0 ? (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Encryption" }), notice && (_jsx("p", { role: "status", className: "mb-2 py-2 px-3 rounded-sm bg-surface-alt text-text", children: notice })), keyConflicts.map((conflict) => {
@@ -5,13 +5,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useState } from "react";
7
7
  import { ApiRequestError } from "../../../../lib/util/api.js";
8
- import { contactPhotoUrl, createContact, deleteContactPhoto, updateContact, uploadContactPhoto, } from "../../../../lib/contacts/contactsApi.js";
8
+ import { createContact, deleteContactPhoto, getContact, updateContact, uploadContactPhoto, } from "../../../../lib/contacts/contactsApi.js";
9
9
  import Alert from "../../../../lib/components/feedback/Alert.js";
10
10
  import Button from "../../../../lib/components/buttons/Button.js";
11
11
  import FormField from "../../../../lib/components/forms/FormField.js";
12
12
  import ContactPhotoField from "./ContactPhotoField.js";
13
13
  import FavoriteStarButton from "./FavoriteStarButton.js";
14
14
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
15
+ import { useContactPhotoSrc } from "../../../../lib/contacts/useContactPhotoSrc.js";
15
16
  import { findWellKnownFolderUid } from "../../mail/findWellKnownFolderUid.js";
16
17
  import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
17
18
  const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
@@ -24,6 +25,7 @@ const ADDRESS_KINDS = ["home", "work", "other"];
24
25
  */
25
26
  export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes, onSaved, onCancel }) {
26
27
  const client = useApiClient();
28
+ const currentPhotoUrl = useContactPhotoSrc(contact, client);
27
29
  const [targetMailboxUid, setTargetMailboxUid] = useState(mailboxUid);
28
30
  const [displayName, setDisplayName] = useState(contact?.displayName ?? "");
29
31
  const [givenName, setGivenName] = useState(contact?.givenName ?? "");
@@ -41,8 +43,13 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
41
43
  // A picture chosen (or the current one dropped) is applied once the contact itself is saved.
42
44
  const [pickedPhoto, setPickedPhoto] = useState(null);
43
45
  const [removePhoto, setRemovePhoto] = useState(false);
46
+ // A chosen picture is being made small enough (see `ContactPhotoField`): the contact is not saved before it is ready.
47
+ const [preparingPhoto, setPreparingPhoto] = useState(false);
44
48
  const [error, setError] = useState(null);
45
49
  const [saving, setSaving] = useState(false);
50
+ // The contact as stored, once it has been saved and only the picture is left to do (that is a second request, which can fail on its own):
51
+ // a retry must not create the contact again or send the version it started from. `key` is the form's content that was saved.
52
+ const [stored, setStored] = useState(null);
46
53
  function updateEmail(index, patch) {
47
54
  setEmails((prev) => prev.map((e, i) => (i === index ? { ...e, ...patch } : e)));
48
55
  }
@@ -76,7 +83,8 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
76
83
  .filter(Boolean);
77
84
  // An update merges only the fields in its body, so a field the user cleared is sent as an
78
85
  // explicit `null` (an omitted/undefined one would keep its stored value); a create just omits it.
79
- const cleared = contact ? null : undefined;
86
+ const existing = stored?.contact ?? contact;
87
+ const cleared = existing ? null : undefined;
80
88
  const input = {
81
89
  displayName: displayName.trim(),
82
90
  givenName: givenName.trim() || cleared,
@@ -86,15 +94,21 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
86
94
  notes: notes.trim() || cleared,
87
95
  favorite,
88
96
  categories,
89
- emails,
90
- phones,
97
+ // A row added with "+ Add" and left empty is not sent: the server refuses a contact with a blank address or number.
98
+ emails: emails.filter((email) => email.address.trim()).map((email) => ({ ...email, address: email.address.trim() })),
99
+ phones: phones.filter((phone) => phone.phoneNumber.trim()).map((phone) => ({ ...phone, phoneNumber: phone.phoneNumber.trim() })),
91
100
  addresses,
92
101
  };
102
+ const key = JSON.stringify([targetMailboxUid, input]);
93
103
  let saved;
94
- if (contact) {
104
+ if (stored && stored.key === key) {
105
+ // Stored exactly as it is now: only the picture is left.
106
+ saved = stored.contact;
107
+ }
108
+ else if (existing) {
95
109
  saved = await updateContact({
96
- uid: contact.uid,
97
- version: contact.version,
110
+ uid: existing.uid,
111
+ version: existing.version,
98
112
  ...input,
99
113
  }, client);
100
114
  }
@@ -112,15 +126,34 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
112
126
  ...input,
113
127
  }, client);
114
128
  }
115
- if (pickedPhoto) {
116
- saved = await uploadContactPhoto(saved.uid, saved.version, pickedPhoto);
129
+ setStored({ contact: saved, key });
130
+ const changePhoto = async (current) => {
131
+ if (pickedPhoto) {
132
+ return uploadContactPhoto(current.uid, current.version, pickedPhoto, client);
133
+ }
134
+ return removePhoto && current.photoBlobKey ? deleteContactPhoto(current.uid, current.version, client) : current;
135
+ };
136
+ try {
137
+ try {
138
+ saved = await changePhoto(saved);
139
+ }
140
+ catch (err) {
141
+ if (!(err instanceof ApiRequestError && err.status === 409)) {
142
+ throw err;
143
+ }
144
+ // A version that is stale means an earlier try had been applied after all (its answer never arrived): read the contact as it is and go again, once.
145
+ const current = await getContact(saved.uid, client);
146
+ setStored({ contact: current, key });
147
+ saved = await changePhoto(current);
148
+ }
117
149
  }
118
- else if (removePhoto && saved.photoBlobKey) {
119
- saved = await deleteContactPhoto(saved.uid, saved.version, client);
150
+ catch (err) {
151
+ // The contact is saved: stay open so the picture can be tried again, or let go of.
152
+ const reason = err instanceof ApiRequestError ? err.message.replace(/\.$/, "") : "the server could not be reached";
153
+ setError(`Saved, but the picture could not be ${pickedPhoto ? "uploaded" : "removed"}: ${reason}.`);
154
+ return;
120
155
  }
121
- // A message opened later in this page load must see the changed contact's signing keys.
122
- clearPinnedSignerCache();
123
- onSaved(saved);
156
+ finish(saved);
124
157
  }
125
158
  catch (err) {
126
159
  setError(err instanceof ApiRequestError ? err.message : "Could not save this contact.");
@@ -129,12 +162,17 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
129
162
  setSaving(false);
130
163
  }
131
164
  }
132
- return (_jsxs("form", { onSubmit: handleSubmit, className: "@container flex min-h-0 flex-1 flex-col", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-6", children: _jsxs("div", { className: "flex max-w-xl flex-col gap-1", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-3", children: contact ? "Edit contact" : "New contact" }), error && _jsx(Alert, { children: error }), !contact && mailboxes && mailboxes.length > 1 && (_jsx(FormField, { label: "Mailbox", htmlFor: "contact-mailbox", children: _jsx("select", { id: "contact-mailbox", className: `${SELECT_CLASS} w-full`, value: targetMailboxUid, onChange: (e) => setTargetMailboxUid(e.target.value), children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) }) })), _jsx(FormField, { label: "Display name", htmlFor: "contact-displayName", children: _jsx("input", { id: "contact-displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value) }) }), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("div", { className: "min-w-0 flex-1", children: _jsx(ContactPhotoField, { displayName: displayName, email: emails[0]?.address, currentUrl: contact && contactPhotoUrl(contact), picked: pickedPhoto, removed: removePhoto, onPick: (file) => {
165
+ function finish(saved) {
166
+ // A message opened later in this page load must see the changed contact's signing keys.
167
+ clearPinnedSignerCache();
168
+ onSaved(saved);
169
+ }
170
+ return (_jsxs("form", { onSubmit: handleSubmit, className: "@container flex min-h-0 flex-1 flex-col", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-6", children: _jsxs("div", { className: "flex max-w-xl flex-col gap-1", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-3", children: contact ? "Edit contact" : "New contact" }), error && _jsx(Alert, { children: error }), !contact && mailboxes && mailboxes.length > 1 && (_jsx(FormField, { label: "Mailbox", htmlFor: "contact-mailbox", children: _jsx("select", { id: "contact-mailbox", className: `${SELECT_CLASS} w-full`, value: targetMailboxUid, onChange: (e) => setTargetMailboxUid(e.target.value), children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) }) })), _jsx(FormField, { label: "Display name", htmlFor: "contact-displayName", children: _jsx("input", { id: "contact-displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value) }) }), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("div", { className: "min-w-0 flex-1", children: _jsx(ContactPhotoField, { displayName: displayName, email: emails[0]?.address, currentUrl: currentPhotoUrl, picked: pickedPhoto, removed: removePhoto, onPick: (file) => {
133
171
  setPickedPhoto(file);
134
172
  setRemovePhoto(false);
135
173
  setError(null);
136
174
  }, onRemove: () => {
137
175
  setPickedPhoto(null);
138
176
  setRemovePhoto(true);
139
- }, onReject: setError }) }), _jsx(FavoriteStarButton, { favorite: favorite, onToggle: () => setFavorite((value) => !value) })] }), _jsxs("div", { className: "grid grid-cols-1 @md:grid-cols-2 gap-3", children: [_jsx(FormField, { label: "First name", htmlFor: "contact-givenName", children: _jsx("input", { id: "contact-givenName", type: "text", className: INPUT_CLASS, value: givenName, onChange: (e) => setGivenName(e.target.value) }) }), _jsx(FormField, { label: "Last name", htmlFor: "contact-surname", children: _jsx("input", { id: "contact-surname", type: "text", className: INPUT_CLASS, value: surname, onChange: (e) => setSurname(e.target.value) }) })] }), _jsxs("div", { className: "grid grid-cols-1 @md:grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Company", htmlFor: "contact-company", children: _jsx("input", { id: "contact-company", type: "text", className: INPUT_CLASS, value: company, onChange: (e) => setCompany(e.target.value) }) }), _jsx(FormField, { label: "Job title", htmlFor: "contact-jobTitle", children: _jsx("input", { id: "contact-jobTitle", type: "text", className: INPUT_CLASS, value: jobTitle, onChange: (e) => setJobTitle(e.target.value) }) })] }), _jsx(FormField, { label: "Categories (comma-separated)", htmlFor: "contact-categories", children: _jsx("input", { id: "contact-categories", type: "text", className: INPUT_CLASS, value: categoriesText, onChange: (e) => setCategoriesText(e.target.value), placeholder: "VIP, Work" }) }), _jsx(FormField, { label: "Email", htmlFor: "contact-emails", children: _jsxs("div", { id: "contact-emails", className: "flex flex-col gap-2", children: [emails.map((email, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "email", className: `${INPUT_CLASS} flex-1`, value: email.address, onChange: (e) => updateEmail(i, { address: e.target.value }), "aria-label": `Email address ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: email.type, onChange: (e) => updateEmail(i, { type: e.target.value }), "aria-label": `Email type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) }), _jsx("button", { type: "button", onClick: () => removeEmail(i), className: "text-sm text-danger px-2", "aria-label": `Remove email ${i + 1}`, children: "\u00D7" })] }, i))), _jsx("button", { type: "button", onClick: () => setEmails((prev) => [...prev, { address: "", type: "work" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add email" })] }) }), _jsx(FormField, { label: "Phone", htmlFor: "contact-phones", children: _jsxs("div", { id: "contact-phones", className: "flex flex-col gap-2", children: [phones.map((phone, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "tel", className: `${INPUT_CLASS} flex-1`, value: phone.phoneNumber, onChange: (e) => updatePhone(i, { phoneNumber: e.target.value }), "aria-label": `Phone number ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: phone.type, onChange: (e) => updatePhone(i, { type: e.target.value }), "aria-label": `Phone type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) }), _jsx("button", { type: "button", onClick: () => removePhone(i), className: "text-sm text-danger px-2", "aria-label": `Remove phone ${i + 1}`, children: "\u00D7" })] }, i))), _jsx("button", { type: "button", onClick: () => setPhones((prev) => [...prev, { phoneNumber: "", type: "work" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add phone" })] }) }), _jsx(FormField, { label: "Address", htmlFor: "contact-address", children: _jsxs("div", { className: "flex flex-col gap-3", children: [addresses.map((address, i) => (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "grid grid-cols-1 @md:grid-cols-2 gap-2", children: [_jsx("input", { type: "text", placeholder: "Street", className: INPUT_CLASS, value: address.street ?? "", onChange: (e) => updateAddress(i, { street: e.target.value }) }), _jsx("input", { type: "text", placeholder: "City", className: INPUT_CLASS, value: address.city ?? "", onChange: (e) => updateAddress(i, { city: e.target.value }) }), _jsx("input", { type: "text", placeholder: "State/Province", className: INPUT_CLASS, value: address.state ?? "", onChange: (e) => updateAddress(i, { state: e.target.value }) }), _jsx("input", { type: "text", placeholder: "Postal code", className: INPUT_CLASS, value: address.postalCode ?? "", onChange: (e) => updateAddress(i, { postalCode: e.target.value }) }), _jsx("input", { type: "text", placeholder: "Country", className: INPUT_CLASS, value: address.country ?? "", onChange: (e) => updateAddress(i, { country: e.target.value }) }), _jsx("select", { className: SELECT_CLASS, value: address.type, onChange: (e) => updateAddress(i, { type: e.target.value }), "aria-label": i === 0 ? "Address type" : `Address type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) })] }), _jsx("button", { type: "button", onClick: () => removeAddress(i), className: "self-start text-xs font-medium text-danger hover:underline", "aria-label": i === 0 ? "Remove address" : `Remove address ${i + 1}`, children: "Remove address" })] }, i))), _jsx("button", { type: "button", onClick: () => setAddresses((prev) => [...prev, { type: "home" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add address" })] }) }), _jsx(FormField, { label: "Notes", htmlFor: "contact-notes", children: _jsx("textarea", { id: "contact-notes", rows: 3, className: INPUT_CLASS, value: notes, onChange: (e) => setNotes(e.target.value) }) })] }) }), _jsxs("div", { className: "rr-solid sticky bottom-14 z-10 flex shrink-0 gap-3 border-t border-border bg-surface px-6 py-3 md:bottom-0", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onCancel, children: "Cancel" })] })] }));
177
+ }, onReject: setError, onBusyChange: setPreparingPhoto }) }), _jsx(FavoriteStarButton, { favorite: favorite, onToggle: () => setFavorite((value) => !value) })] }), _jsxs("div", { className: "grid grid-cols-1 @md:grid-cols-2 gap-3", children: [_jsx(FormField, { label: "First name", htmlFor: "contact-givenName", children: _jsx("input", { id: "contact-givenName", type: "text", className: INPUT_CLASS, value: givenName, onChange: (e) => setGivenName(e.target.value) }) }), _jsx(FormField, { label: "Last name", htmlFor: "contact-surname", children: _jsx("input", { id: "contact-surname", type: "text", className: INPUT_CLASS, value: surname, onChange: (e) => setSurname(e.target.value) }) })] }), _jsxs("div", { className: "grid grid-cols-1 @md:grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Company", htmlFor: "contact-company", children: _jsx("input", { id: "contact-company", type: "text", className: INPUT_CLASS, value: company, onChange: (e) => setCompany(e.target.value) }) }), _jsx(FormField, { label: "Job title", htmlFor: "contact-jobTitle", children: _jsx("input", { id: "contact-jobTitle", type: "text", className: INPUT_CLASS, value: jobTitle, onChange: (e) => setJobTitle(e.target.value) }) })] }), _jsx(FormField, { label: "Categories (comma-separated)", htmlFor: "contact-categories", children: _jsx("input", { id: "contact-categories", type: "text", className: INPUT_CLASS, value: categoriesText, onChange: (e) => setCategoriesText(e.target.value), placeholder: "VIP, Work" }) }), _jsx(FormField, { label: "Email", htmlFor: "contact-emails", children: _jsxs("div", { id: "contact-emails", className: "flex flex-col gap-2", children: [emails.map((email, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "email", className: `${INPUT_CLASS} flex-1`, value: email.address, onChange: (e) => updateEmail(i, { address: e.target.value }), "aria-label": `Email address ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: email.type, onChange: (e) => updateEmail(i, { type: e.target.value }), "aria-label": `Email type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) }), _jsx("button", { type: "button", onClick: () => removeEmail(i), className: "text-sm text-danger px-2", "aria-label": `Remove email ${i + 1}`, children: "\u00D7" })] }, i))), _jsx("button", { type: "button", onClick: () => setEmails((prev) => [...prev, { address: "", type: "work" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add email" })] }) }), _jsx(FormField, { label: "Phone", htmlFor: "contact-phones", children: _jsxs("div", { id: "contact-phones", className: "flex flex-col gap-2", children: [phones.map((phone, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "tel", className: `${INPUT_CLASS} flex-1`, value: phone.phoneNumber, onChange: (e) => updatePhone(i, { phoneNumber: e.target.value }), "aria-label": `Phone number ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: phone.type, onChange: (e) => updatePhone(i, { type: e.target.value }), "aria-label": `Phone type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) }), _jsx("button", { type: "button", onClick: () => removePhone(i), className: "text-sm text-danger px-2", "aria-label": `Remove phone ${i + 1}`, children: "\u00D7" })] }, i))), _jsx("button", { type: "button", onClick: () => setPhones((prev) => [...prev, { phoneNumber: "", type: "work" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add phone" })] }) }), _jsx(FormField, { label: "Address", htmlFor: "contact-address", children: _jsxs("div", { className: "flex flex-col gap-3", children: [addresses.map((address, i) => (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "grid grid-cols-1 @md:grid-cols-2 gap-2", children: [_jsx("input", { type: "text", placeholder: "Street", className: INPUT_CLASS, value: address.street ?? "", onChange: (e) => updateAddress(i, { street: e.target.value }) }), _jsx("input", { type: "text", placeholder: "City", className: INPUT_CLASS, value: address.city ?? "", onChange: (e) => updateAddress(i, { city: e.target.value }) }), _jsx("input", { type: "text", placeholder: "State/Province", className: INPUT_CLASS, value: address.state ?? "", onChange: (e) => updateAddress(i, { state: e.target.value }) }), _jsx("input", { type: "text", placeholder: "Postal code", className: INPUT_CLASS, value: address.postalCode ?? "", onChange: (e) => updateAddress(i, { postalCode: e.target.value }) }), _jsx("input", { type: "text", placeholder: "Country", className: INPUT_CLASS, value: address.country ?? "", onChange: (e) => updateAddress(i, { country: e.target.value }) }), _jsx("select", { className: SELECT_CLASS, value: address.type, onChange: (e) => updateAddress(i, { type: e.target.value }), "aria-label": i === 0 ? "Address type" : `Address type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) })] }), _jsx("button", { type: "button", onClick: () => removeAddress(i), className: "self-start text-xs font-medium text-danger hover:underline", "aria-label": i === 0 ? "Remove address" : `Remove address ${i + 1}`, children: "Remove address" })] }, i))), _jsx("button", { type: "button", onClick: () => setAddresses((prev) => [...prev, { type: "home" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add address" })] }) }), _jsx(FormField, { label: "Notes", htmlFor: "contact-notes", children: _jsx("textarea", { id: "contact-notes", rows: 3, className: INPUT_CLASS, value: notes, onChange: (e) => setNotes(e.target.value) }) })] }) }), _jsxs("div", { className: "rr-solid sticky bottom-14 z-10 flex shrink-0 gap-3 border-t border-border bg-surface px-6 py-3 md:bottom-0", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving || preparingPhoto, className: "!w-auto", children: "Save" }), stored && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: saving, onClick: () => finish(stored.contact), children: "Continue without the picture" })), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: stored ? () => finish(stored.contact) : onCancel, children: "Cancel" })] })] }));
140
178
  }
@@ -0,0 +1,21 @@
1
+ import React from "react";
2
+ import { type Contact } from "../../../../lib/contacts/contactsApi.js";
3
+ export interface ContactPhotoEditorProps {
4
+ /** The stored contact whose picture is changed. */
5
+ contact: Contact;
6
+ displayName: string;
7
+ size: number;
8
+ /** The contact's address, for the Gravatar shown while they have no picture of their own. */
9
+ email?: string;
10
+ /** `"sm"` for an avatar too small for the default badge. */
11
+ badgeSize?: "sm" | "md";
12
+ /** Called with the contact as stored after its picture was set or removed. */
13
+ onChanged: (contact: Contact) => void;
14
+ /** Told whether a change is under way, for what must not edit the contact meanwhile (its star: a second change would send the version this one is about to replace). */
15
+ onBusyChange?: (busy: boolean) => void;
16
+ }
17
+ /**
18
+ * A stored contact's avatar with the camera badge of `ImageEditBadge` at its lower right: unlike the edit form (`ContactPhotoField`), which keeps a chosen picture until the
19
+ * contact is saved, a picture chosen or taken here is made ready and saved at once, and removing it is too. A refusal (a view-only share, a stale version) is a pop-up.
20
+ */
21
+ export default function ContactPhotoEditor({ contact, displayName, size, email, badgeSize, onChanged, onBusyChange }: ContactPhotoEditorProps): React.JSX.Element;
@@ -0,0 +1,56 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useRef, useState } from "react";
7
+ import ContactPhotoAvatar from "../../../../lib/components/avatar/ContactPhotoAvatar.js";
8
+ import ImageEditBadge from "../../../../lib/components/forms/ImageEditBadge.js";
9
+ import { deleteContactPhoto, uploadContactPhoto } from "../../../../lib/contacts/contactsApi.js";
10
+ import { ContactPhotoError, prepareContactPhoto } from "../../../../lib/contacts/preparePhoto.js";
11
+ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
12
+ import { notify } from "../../notifications/store.js";
13
+ import { notifyApiError } from "../../notifications/apiErrors.js";
14
+ const FAILURE_TITLE = "Couldn't change this contact's photo";
15
+ /**
16
+ * A stored contact's avatar with the camera badge of `ImageEditBadge` at its lower right: unlike the edit form (`ContactPhotoField`), which keeps a chosen picture until the
17
+ * contact is saved, a picture chosen or taken here is made ready and saved at once, and removing it is too. A refusal (a view-only share, a stale version) is a pop-up.
18
+ */
19
+ export default function ContactPhotoEditor({ contact, displayName, size, email, badgeSize, onChanged, onBusyChange }) {
20
+ const client = useApiClient();
21
+ const [busy, setBusy] = useState(false);
22
+ const root = useRef(null);
23
+ // Preparing a picture takes seconds; what is uploaded against is the contact as it is by then (it may have been read again), not as it was when the picture was chosen.
24
+ const latest = useRef(contact);
25
+ latest.current = contact;
26
+ const wasBusy = useRef(false);
27
+ useEffect(() => {
28
+ onBusyChange?.(busy);
29
+ // The badge is disabled meanwhile, which drops its focus: it gets it back, unless the user has gone elsewhere.
30
+ if (wasBusy.current && !busy && (!document.activeElement || document.activeElement === document.body)) {
31
+ root.current?.querySelector('button[aria-haspopup="menu"]')?.focus();
32
+ }
33
+ wasBusy.current = busy;
34
+ }, [busy]);
35
+ async function change(work) {
36
+ setBusy(true);
37
+ try {
38
+ onChanged(await work());
39
+ }
40
+ catch (err) {
41
+ if (err instanceof ContactPhotoError) {
42
+ notify({ kind: "error", title: FAILURE_TITLE, message: err.message });
43
+ }
44
+ else {
45
+ notifyApiError(err, FAILURE_TITLE);
46
+ }
47
+ }
48
+ finally {
49
+ setBusy(false);
50
+ }
51
+ }
52
+ return (_jsxs("div", { ref: root, className: "relative inline-block shrink-0", children: [_jsx(ContactPhotoAvatar, { displayName: displayName, size: size, contact: contact, email: email }), _jsx(ImageEditBadge, { position: "bottom-right", size: badgeSize, label: "Change contact photo", fileInputLabel: "Contact photo file", hasImage: !!contact.photoBlobKey, busy: busy, onFile: (file) => void change(async () => {
53
+ const prepared = await prepareContactPhoto(file);
54
+ return uploadContactPhoto(latest.current.uid, latest.current.version, prepared, client);
55
+ }), onRemove: () => void change(() => deleteContactPhoto(latest.current.uid, latest.current.version, client)) })] }));
56
+ }
@@ -3,7 +3,7 @@ export interface ContactPhotoFieldProps {
3
3
  displayName: string;
4
4
  /** The contact's first address, for the Gravatar shown while they have no picture of their own. */
5
5
  email?: string;
6
- /** The picture the contact has now (`contactPhotoUrl()`), if any. */
6
+ /** The picture the contact has now (`useContactPhotoSrc()`), if any. */
7
7
  currentUrl?: string;
8
8
  /** The picture chosen to replace it, not uploaded until the contact is saved; `null` for none. */
9
9
  picked: File | null;
@@ -13,9 +13,12 @@ export interface ContactPhotoFieldProps {
13
13
  onRemove: () => void;
14
14
  /** Called with the reason a chosen file was turned down. */
15
15
  onReject: (message: string) => void;
16
+ /** Called with `true` while a chosen file is being made ready, so the form is not saved before it is, and with `false` after. */
17
+ onBusyChange: (busy: boolean) => void;
16
18
  }
17
19
  /**
18
- * The picture of a contact in the edit form: the avatar as it is (their picture, else Gravatar, else initials) with "Change photo" and, when
19
- * there is a picture of their own, "Remove photo". A file is checked here (type and size) and only uploaded when the form is saved.
20
+ * The picture of a contact in the edit form: the avatar as it is (their picture, else Gravatar, else initials) with a camera badge at its lower right that opens a
21
+ * menu (`ImageEditBadge`) - "Upload file", "Take photo" and, when there is a picture of their own, "Remove photo". Any picture is taken (a phone's camera photo of several megabytes,
22
+ * in any orientation, included): it is made small enough here by `prepareContactPhoto()` and only uploaded when the form is saved.
20
23
  */
21
- export default function ContactPhotoField({ displayName, email, currentUrl, picked, removed, onPick, onRemove, onReject }: ContactPhotoFieldProps): React.JSX.Element;
24
+ export default function ContactPhotoField({ displayName, email, currentUrl, picked, removed, onPick, onRemove, onReject, onBusyChange }: ContactPhotoFieldProps): React.JSX.Element;
@@ -3,17 +3,18 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { useEffect, useRef, useState } from "react";
6
+ import { useEffect, useState } from "react";
7
7
  import ContactAvatar from "../../../../lib/components/avatar/ContactAvatar.js";
8
- import Button from "../../../../lib/components/buttons/Button.js";
9
- import { CONTACT_PHOTO_MAX_BYTES, CONTACT_PHOTO_TYPES } from "../../../../lib/contacts/contactsApi.js";
8
+ import { ContactPhotoError, prepareContactPhoto } from "../../../../lib/contacts/preparePhoto.js";
9
+ import ImageEditBadge from "../../../../lib/components/forms/ImageEditBadge.js";
10
10
  /**
11
- * The picture of a contact in the edit form: the avatar as it is (their picture, else Gravatar, else initials) with "Change photo" and, when
12
- * there is a picture of their own, "Remove photo". A file is checked here (type and size) and only uploaded when the form is saved.
11
+ * The picture of a contact in the edit form: the avatar as it is (their picture, else Gravatar, else initials) with a camera badge at its lower right that opens a
12
+ * menu (`ImageEditBadge`) - "Upload file", "Take photo" and, when there is a picture of their own, "Remove photo". Any picture is taken (a phone's camera photo of several megabytes,
13
+ * in any orientation, included): it is made small enough here by `prepareContactPhoto()` and only uploaded when the form is saved.
13
14
  */
14
- export default function ContactPhotoField({ displayName, email, currentUrl, picked, removed, onPick, onRemove, onReject }) {
15
- const input = useRef(null);
15
+ export default function ContactPhotoField({ displayName, email, currentUrl, picked, removed, onPick, onRemove, onReject, onBusyChange }) {
16
16
  const [preview, setPreview] = useState();
17
+ const [preparing, setPreparing] = useState(false);
17
18
  useEffect(() => {
18
19
  if (!picked) {
19
20
  setPreview(undefined);
@@ -23,24 +24,21 @@ export default function ContactPhotoField({ displayName, email, currentUrl, pick
23
24
  setPreview(url);
24
25
  return () => URL.revokeObjectURL(url);
25
26
  }, [picked]);
26
- function handleChange(file) {
27
- if (!file) {
28
- return;
29
- }
30
- if (!CONTACT_PHOTO_TYPES.includes(file.type)) {
31
- onReject("Choose a JPEG, PNG, GIF or WebP image.");
27
+ async function handleChange(file) {
28
+ setPreparing(true);
29
+ onBusyChange(true);
30
+ try {
31
+ onPick(await prepareContactPhoto(file));
32
32
  }
33
- else if (file.size > CONTACT_PHOTO_MAX_BYTES) {
34
- onReject("Choose an image no larger than 1 MB.");
33
+ catch (err) {
34
+ onReject(err instanceof ContactPhotoError ? err.message : "This picture could not be read - choose another one.");
35
35
  }
36
- else {
37
- onPick(file);
36
+ finally {
37
+ setPreparing(false);
38
+ onBusyChange(false);
38
39
  }
39
40
  }
40
41
  const shown = picked ? preview : removed ? undefined : currentUrl;
41
42
  const hasOwn = !!picked || (!!currentUrl && !removed);
42
- return (_jsxs("div", { className: "flex items-center gap-4 mb-3", children: [_jsx(ContactAvatar, { displayName: displayName || "?", size: 72, photoUrl: shown, email: picked || (currentUrl && !removed) ? undefined : email }), _jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsx("input", { ref: input, type: "file", accept: CONTACT_PHOTO_TYPES.join(","), "aria-label": "Contact photo file", className: "sr-only", tabIndex: -1, onChange: (e) => {
43
- handleChange(e.target.files?.[0]);
44
- e.target.value = "";
45
- } }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto !py-1 !px-3 text-xs", onClick: () => input.current?.click(), children: hasOwn ? "Change photo" : "Add photo" }), hasOwn && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto !py-1 !px-3 text-xs", onClick: onRemove, children: "Remove photo" }))] })] }));
43
+ return (_jsxs("div", { className: "flex items-center gap-4 mb-3", children: [_jsxs("div", { className: "relative inline-block shrink-0", children: [_jsx(ContactAvatar, { displayName: displayName || "?", size: 72, photoUrl: shown, email: picked || (currentUrl && !removed) ? undefined : email }), _jsx(ImageEditBadge, { position: "bottom-right", label: "Change contact photo", fileInputLabel: "Contact photo file", hasImage: hasOwn, busy: preparing, onFile: (file) => void handleChange(file), onRemove: onRemove })] }), preparing && (_jsx("span", { role: "status", className: "text-xs text-text-muted", children: "Preparing picture\u2026" }))] }));
46
44
  }
@@ -79,7 +79,7 @@ export default function ContactsShell({ userUid, authServerUrl, impersonating, i
79
79
  // A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else
80
80
  // for a mailbox-less caller to do here yet, so the icon rail/header don't render at all.
81
81
  if (userUid && status === "ready" && !mailboxUid) {
82
- return _jsx(MailboxProvisioning, {});
82
+ return _jsx(MailboxProvisioning, { impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl });
83
83
  }
84
84
  let inner = null;
85
85
  if (userUid && status === "checking") {
@@ -52,7 +52,10 @@ export declare function contactInputFromCard(card: ParsedVCardContact, scope: {
52
52
  mailboxUid: string;
53
53
  folderUid: string;
54
54
  }): KnownPerson;
55
- /** The cards that are not yet in the address book, and how many were: a card is a duplicate when one of its emails is a stored contact's or an earlier card's. */
55
+ /**
56
+ * The cards that are not yet in the address book, and how many were: a card is a duplicate when one of its emails is a stored contact's or an earlier card's,
57
+ * and a card with no email at all when its name and first phone number are a stored contact's or an earlier card's (so importing the same file twice adds nothing).
58
+ */
56
59
  export declare function withoutDuplicates(cards: readonly ParsedVCardContact[], existing: readonly Contact[]): {
57
60
  fresh: ParsedVCardContact[];
58
61
  skipped: number;
@@ -92,12 +92,28 @@ export function contactInputFromCard(card, scope) {
92
92
  const names = card.givenName || card.surname ? { givenName: card.givenName, surname: card.surname } : splitPersonName(card.displayName);
93
93
  return { ...card, ...scope, ...names, emails: card.emails ?? [], phones: card.phones ?? [], addresses: card.addresses ?? [] };
94
94
  }
95
- /** The cards that are not yet in the address book, and how many were: a card is a duplicate when one of its emails is a stored contact's or an earlier card's. */
95
+ /** What a card with no email is told apart by: its name and its first phone number (digits only). */
96
+ function identityKey(person) {
97
+ return `${person.displayName.trim().toLowerCase()}|${(person.phones?.[0]?.phoneNumber ?? "").replace(/\D/g, "")}`;
98
+ }
99
+ /**
100
+ * The cards that are not yet in the address book, and how many were: a card is a duplicate when one of its emails is a stored contact's or an earlier card's,
101
+ * and a card with no email at all when its name and first phone number are a stored contact's or an earlier card's (so importing the same file twice adds nothing).
102
+ */
96
103
  export function withoutDuplicates(cards, existing) {
97
104
  const known = new Set(existing.flatMap((contact) => contact.emails.map((email) => normalizeAddress(email.address))));
105
+ const knownIdentities = new Set(existing.map(identityKey));
98
106
  const fresh = [];
99
107
  for (const card of cards) {
100
108
  const addresses = (card.emails ?? []).map((email) => normalizeAddress(email.address));
109
+ if (addresses.length === 0) {
110
+ const identity = identityKey(card);
111
+ if (!knownIdentities.has(identity)) {
112
+ knownIdentities.add(identity);
113
+ fresh.push(card);
114
+ }
115
+ continue;
116
+ }
101
117
  if (addresses.some((address) => known.has(address))) {
102
118
  continue;
103
119
  }