@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.
- package/apps/admin/audit-log/index.tsx +3 -3
- package/apps/admin/branding/index.tsx +3 -3
- package/apps/admin/data-requests/index.tsx +18 -7
- package/apps/admin/distribution-lists/[uid].tsx +3 -3
- package/apps/admin/distribution-lists/index.tsx +3 -3
- package/apps/admin/distribution-lists/new/index.tsx +3 -3
- package/apps/admin/domains/[uid].tsx +6 -5
- package/apps/admin/domains/index.tsx +3 -3
- package/apps/admin/domains/new/index.tsx +3 -3
- package/apps/admin/escrow-scopes/[uid].tsx +11 -5
- package/apps/admin/escrow-scopes/index.tsx +3 -3
- package/apps/admin/escrow-scopes/new/index.tsx +9 -3
- package/apps/admin/index.tsx +4 -4
- package/apps/admin/ingest-queue/index.tsx +3 -3
- package/apps/admin/mailboxes/[uid].tsx +15 -7
- package/apps/admin/quarantine/index.tsx +4 -4
- package/apps/admin/retention-policy/index.tsx +3 -3
- package/apps/admin/signing-certificates/index.tsx +3 -3
- package/apps/admin/transport-rules/[uid].tsx +4 -4
- package/apps/admin/transport-rules/index.tsx +3 -3
- package/apps/admin/transport-rules/new/index.tsx +3 -3
- package/apps/escrow/matters/[uid].tsx +26 -2
- package/apps/escrow/matters/new/index.tsx +4 -0
- package/apps/shared/calendar/calendarLiveUpdates.ts +11 -10
- package/apps/shared/calendar/useCalendarReminders.ts +2 -0
- package/apps/shared/components/admin/ActionAlert.tsx +32 -0
- package/apps/shared/components/admin/diagnostics/InformationPanel.tsx +3 -3
- package/apps/shared/components/admin/diagnostics/InstalledPlugins.tsx +1 -1
- package/apps/shared/components/admin/diagnostics/LogsPanel.tsx +1 -1
- package/apps/shared/components/admin/diagnostics/RuntimePanel.tsx +1 -1
- package/apps/shared/components/admin/diagnostics/SystemPanel.tsx +1 -1
- package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +2 -2
- package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +4 -4
- package/apps/shared/components/admin/elevation.ts +19 -0
- package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +58 -3
- package/apps/shared/components/admin/layout/AdminShell.tsx +6 -1
- package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +3 -3
- package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +4 -4
- package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +4 -4
- package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +6 -6
- package/apps/shared/components/admin/settings/BrandingForm.tsx +22 -9
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -4
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +33 -4
- package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +3 -3
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +8 -3
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +3 -3
- package/apps/shared/components/admin/settings/PluginsManager.tsx +195 -46
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +25 -12
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +40 -7
- package/apps/shared/components/admin/setup/SetupWizard.tsx +8 -6
- package/apps/shared/components/admin/signOut.ts +3 -0
- package/apps/shared/components/admin/usePagedList.tsx +2 -2
- package/apps/shared/components/calendar/DescriptionEditor.tsx +2 -1
- package/apps/shared/components/calendar/EventDetails.tsx +34 -8
- package/apps/shared/components/calendar/EventEditor.tsx +90 -40
- package/apps/shared/components/calendar/EventFormParts.tsx +5 -2
- package/apps/shared/components/calendar/EventMatchNav.tsx +14 -2
- package/apps/shared/components/calendar/EventModal.tsx +25 -1
- package/apps/shared/components/calendar/FindATime.tsx +10 -2
- package/apps/shared/components/calendar/ListView.tsx +99 -25
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +19 -8
- package/apps/shared/components/calendar/SplitDayView.tsx +14 -18
- package/apps/shared/components/calendar/TaskQuickForm.tsx +3 -2
- package/apps/shared/components/calendar/TimeGridView.tsx +35 -19
- package/apps/shared/components/calendar/gridLayout.ts +94 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +1 -1
- package/apps/shared/components/calendar/timePicker.ts +5 -2
- package/apps/shared/components/contacts/ContactCard.tsx +15 -4
- package/apps/shared/components/contacts/ContactDetailPane.tsx +54 -15
- package/apps/shared/components/contacts/ContactForm.tsx +61 -17
- package/apps/shared/components/contacts/ContactPhotoEditor.tsx +88 -0
- package/apps/shared/components/contacts/ContactPhotoField.tsx +36 -39
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +1 -1
- package/apps/shared/components/contacts/participantDetails.ts +18 -1
- package/apps/shared/components/layout/AppShell.tsx +9 -0
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +9 -4
- package/apps/shared/components/layout/MailboxProvisioning.tsx +31 -3
- package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +4 -2
- package/apps/shared/components/mail/ConversationList.tsx +49 -6
- package/apps/shared/components/mail/ConversationThreadPane.tsx +1 -1
- package/apps/shared/components/mail/MenuButton.tsx +3 -400
- package/apps/shared/components/mail/MessageDetailPane.tsx +16 -13
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +11 -1
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +57 -11
- package/apps/shared/components/mail/compose/GifPicker.tsx +10 -4
- package/apps/shared/components/mail/compose/RecipientInput.tsx +9 -7
- package/apps/shared/components/mail/compose/composeFlushRegistry.ts +14 -1
- package/apps/shared/components/mail/compose/encryptionRequirement.ts +5 -0
- package/apps/shared/components/mail/compose/quotedBody.ts +31 -6
- package/apps/shared/components/mail/layout/MailShell.tsx +1 -1
- package/apps/shared/components/mail/reading/MessageBody.tsx +13 -7
- package/apps/shared/components/mail/reading/bodyContent.ts +2 -0
- package/apps/shared/components/mail/reading/bodyHtml.ts +25 -2
- package/apps/shared/components/mail/writableMailboxes.ts +2 -0
- package/apps/shared/components/rules/RuleBuilder.tsx +11 -2
- package/apps/shared/components/settings/GravatarSetting.tsx +27 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -1
- package/apps/shared/components/tasks/dueDate.ts +29 -0
- package/apps/shared/components/tasks/layout/TasksShell.tsx +1 -1
- package/apps/shared/mail/decryptedMessages.ts +51 -2
- package/apps/shared/mail/listSnapshots.ts +2 -0
- package/apps/shared/mail/outbox/sendDecision.ts +14 -3
- package/apps/shared/mail/outbox/sendJob.ts +5 -0
- package/apps/shared/mail/useMailLiveUpdates.ts +7 -1
- package/apps/shared/mail/usePermanentDelete.tsx +3 -1
- package/apps/shared/notifications/apiErrors.ts +2 -0
- package/apps/shared/notifications/preferences.ts +1 -1
- package/apps/shared/notifications/store.ts +3 -1
- package/apps/shared/plugins/pluginNav.ts +4 -2
- package/apps/www/calendar/index.tsx +24 -12
- package/apps/www/contacts/[uid].tsx +6 -1
- package/apps/www/contacts/index.tsx +66 -12
- package/apps/www/index.tsx +91 -28
- package/apps/www/messages/[uid].tsx +8 -3
- package/apps/www/settings/auto-reply/index.tsx +13 -0
- package/apps/www/settings/blocked-senders/index.tsx +4 -0
- package/apps/www/settings/encryption/index.tsx +66 -4
- package/apps/www/settings/privacy/index.tsx +4 -1
- package/apps/www/settings/profile/index.tsx +12 -1
- package/apps/www/settings/sharing/index.tsx +11 -1
- package/apps/www/settings/signatures/[uid].tsx +6 -4
- package/apps/www/tasks/index.tsx +97 -29
- package/dist/apps/admin/audit-log/index.js +3 -3
- package/dist/apps/admin/branding/index.js +3 -3
- package/dist/apps/admin/data-requests/index.js +17 -7
- package/dist/apps/admin/distribution-lists/[uid].js +3 -3
- package/dist/apps/admin/distribution-lists/index.js +3 -3
- package/dist/apps/admin/distribution-lists/new/index.js +3 -3
- package/dist/apps/admin/domains/[uid].js +6 -5
- package/dist/apps/admin/domains/index.js +3 -3
- package/dist/apps/admin/domains/new/index.js +3 -3
- package/dist/apps/admin/escrow-scopes/[uid].js +11 -6
- package/dist/apps/admin/escrow-scopes/index.js +3 -3
- package/dist/apps/admin/escrow-scopes/new/index.js +9 -4
- package/dist/apps/admin/index.js +4 -4
- package/dist/apps/admin/ingest-queue/index.js +3 -3
- package/dist/apps/admin/mailboxes/[uid].js +15 -7
- package/dist/apps/admin/quarantine/index.js +4 -4
- package/dist/apps/admin/retention-policy/index.js +3 -3
- package/dist/apps/admin/signing-certificates/index.js +3 -3
- package/dist/apps/admin/transport-rules/[uid].js +4 -4
- package/dist/apps/admin/transport-rules/index.js +3 -3
- package/dist/apps/admin/transport-rules/new/index.js +3 -3
- package/dist/apps/escrow/matters/[uid].js +8 -3
- package/dist/apps/escrow/matters/new/index.js +4 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +11 -7
- package/dist/apps/shared/calendar/calendarLiveUpdates.js +10 -9
- package/dist/apps/shared/calendar/useCalendarReminders.js +2 -0
- package/dist/apps/shared/components/admin/ActionAlert.d.ts +13 -0
- package/dist/apps/shared/components/admin/ActionAlert.js +24 -0
- package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +2 -2
- package/dist/apps/shared/components/admin/diagnostics/InstalledPlugins.js +1 -1
- package/dist/apps/shared/components/admin/diagnostics/LogsPanel.js +1 -1
- package/dist/apps/shared/components/admin/diagnostics/RuntimePanel.js +1 -1
- package/dist/apps/shared/components/admin/diagnostics/SystemPanel.js +1 -1
- package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +2 -2
- package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +4 -4
- package/dist/apps/shared/components/admin/elevation.d.ts +9 -0
- package/dist/apps/shared/components/admin/elevation.js +16 -0
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.d.ts +2 -0
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +41 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -1
- package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.js +3 -3
- package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +4 -4
- package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.js +4 -4
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +6 -6
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +17 -7
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +4 -4
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +17 -5
- package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +3 -3
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +8 -3
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +3 -3
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +103 -18
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +17 -10
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +18 -8
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +8 -6
- package/dist/apps/shared/components/admin/signOut.js +3 -0
- package/dist/apps/shared/components/admin/usePagedList.js +2 -2
- package/dist/apps/shared/components/calendar/DescriptionEditor.js +2 -1
- package/dist/apps/shared/components/calendar/EventDetails.d.ts +3 -1
- package/dist/apps/shared/components/calendar/EventDetails.js +7 -4
- package/dist/apps/shared/components/calendar/EventEditor.js +81 -33
- package/dist/apps/shared/components/calendar/EventFormParts.d.ts +3 -1
- package/dist/apps/shared/components/calendar/EventFormParts.js +3 -3
- package/dist/apps/shared/components/calendar/EventMatchNav.d.ts +1 -1
- package/dist/apps/shared/components/calendar/EventMatchNav.js +12 -2
- package/dist/apps/shared/components/calendar/EventModal.js +10 -2
- package/dist/apps/shared/components/calendar/FindATime.js +4 -3
- package/dist/apps/shared/components/calendar/ListView.d.ts +10 -4
- package/dist/apps/shared/components/calendar/ListView.js +92 -27
- package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +4 -1
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +15 -10
- package/dist/apps/shared/components/calendar/SplitDayView.js +11 -17
- package/dist/apps/shared/components/calendar/TaskQuickForm.js +3 -2
- package/dist/apps/shared/components/calendar/TimeGridView.js +23 -21
- package/dist/apps/shared/components/calendar/gridLayout.d.ts +41 -0
- package/dist/apps/shared/components/calendar/gridLayout.js +83 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +1 -1
- package/dist/apps/shared/components/calendar/timePicker.js +5 -2
- package/dist/apps/shared/components/contacts/ContactCard.js +9 -3
- package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +5 -3
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -5
- package/dist/apps/shared/components/contacts/ContactForm.js +54 -16
- package/dist/apps/shared/components/contacts/ContactPhotoEditor.d.ts +21 -0
- package/dist/apps/shared/components/contacts/ContactPhotoEditor.js +56 -0
- package/dist/apps/shared/components/contacts/ContactPhotoField.d.ts +7 -4
- package/dist/apps/shared/components/contacts/ContactPhotoField.js +19 -21
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +1 -1
- package/dist/apps/shared/components/contacts/participantDetails.d.ts +4 -1
- package/dist/apps/shared/components/contacts/participantDetails.js +17 -1
- package/dist/apps/shared/components/layout/AppShell.js +9 -0
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +2 -3
- package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +4 -1
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +24 -2
- package/dist/apps/shared/components/layout/RecoveryCodeUnlock.d.ts +4 -2
- package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +4 -2
- package/dist/apps/shared/components/mail/ConversationList.js +49 -6
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +1 -1
- package/dist/apps/shared/components/mail/MenuButton.d.ts +2 -78
- package/dist/apps/shared/components/mail/MenuButton.js +2 -211
- package/dist/apps/shared/components/mail/MessageDetailPane.js +10 -6
- package/dist/apps/shared/components/mail/compose/ComposeToolbar.d.ts +3 -0
- package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +10 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +55 -10
- package/dist/apps/shared/components/mail/compose/GifPicker.js +10 -4
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +9 -7
- package/dist/apps/shared/components/mail/compose/composeFlushRegistry.d.ts +7 -1
- package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +13 -1
- package/dist/apps/shared/components/mail/compose/encryptionRequirement.d.ts +5 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.js +28 -5
- package/dist/apps/shared/components/mail/layout/MailShell.js +1 -1
- package/dist/apps/shared/components/mail/reading/MessageBody.js +11 -6
- package/dist/apps/shared/components/mail/reading/bodyContent.js +2 -0
- package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +5 -1
- package/dist/apps/shared/components/mail/reading/bodyHtml.js +23 -2
- package/dist/apps/shared/components/mail/writableMailboxes.js +2 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +9 -1
- package/dist/apps/shared/components/settings/GravatarSetting.d.ts +6 -0
- package/dist/apps/shared/components/settings/GravatarSetting.js +15 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -1
- package/dist/apps/shared/components/tasks/dueDate.d.ts +12 -0
- package/dist/apps/shared/components/tasks/dueDate.js +26 -0
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +1 -1
- package/dist/apps/shared/mail/decryptedMessages.d.ts +8 -0
- package/dist/apps/shared/mail/decryptedMessages.js +46 -2
- package/dist/apps/shared/mail/listSnapshots.js +2 -0
- package/dist/apps/shared/mail/outbox/sendDecision.d.ts +6 -3
- package/dist/apps/shared/mail/outbox/sendDecision.js +13 -3
- package/dist/apps/shared/mail/outbox/sendJob.d.ts +1 -0
- package/dist/apps/shared/mail/outbox/sendJob.js +5 -1
- package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +3 -0
- package/dist/apps/shared/mail/useMailLiveUpdates.js +6 -1
- package/dist/apps/shared/mail/usePermanentDelete.d.ts +2 -0
- package/dist/apps/shared/mail/usePermanentDelete.js +1 -1
- package/dist/apps/shared/notifications/apiErrors.js +2 -0
- package/dist/apps/shared/notifications/preferences.d.ts +1 -1
- package/dist/apps/shared/notifications/preferences.js +1 -1
- package/dist/apps/shared/notifications/store.js +3 -1
- package/dist/apps/shared/plugins/pluginNav.d.ts +2 -1
- package/dist/apps/shared/plugins/pluginNav.js +4 -2
- package/dist/apps/www/calendar/index.js +24 -12
- package/dist/apps/www/contacts/[uid].js +6 -2
- package/dist/apps/www/contacts/index.js +62 -11
- package/dist/apps/www/index.js +85 -30
- package/dist/apps/www/messages/[uid].js +8 -3
- package/dist/apps/www/settings/auto-reply/index.js +13 -1
- package/dist/apps/www/settings/blocked-senders/index.js +4 -0
- package/dist/apps/www/settings/encryption/index.js +29 -5
- package/dist/apps/www/settings/privacy/index.js +3 -2
- package/dist/apps/www/settings/profile/index.js +4 -1
- package/dist/apps/www/settings/sharing/index.js +10 -1
- package/dist/apps/www/settings/signatures/[uid].js +6 -4
- package/dist/apps/www/tasks/index.js +94 -32
- package/dist/lib/admin/auditLogApi.js +2 -2
- package/dist/lib/admin/distributionListsApi.js +2 -2
- package/dist/lib/admin/domainsApi.js +2 -2
- package/dist/lib/admin/escrowAuditLogApi.js +2 -2
- package/dist/lib/admin/escrowScopesApi.js +2 -2
- package/dist/lib/admin/pluginsApi.d.ts +31 -2
- package/dist/lib/admin/pluginsApi.js +23 -1
- package/dist/lib/admin/retentionPolicyApi.d.ts +3 -0
- package/dist/lib/admin/retentionPolicyApi.js +3 -0
- package/dist/lib/admin/transportRulesApi.js +2 -2
- package/dist/lib/appearance/preferencesApi.d.ts +1 -1
- package/dist/lib/appearance/preferencesApi.js +3 -14
- package/dist/lib/auth/session.d.ts +51 -12
- package/dist/lib/auth/session.js +214 -38
- package/dist/lib/branding/brandingApi.d.ts +3 -3
- package/dist/lib/branding/brandingApi.js +9 -21
- package/dist/lib/calendar/calendarApi.js +3 -1
- package/dist/lib/calendar/eventSearch.js +43 -6
- package/dist/lib/calendar/freeBusyApi.d.ts +2 -1
- package/dist/lib/calendar/freeBusyApi.js +13 -5
- package/dist/lib/calendar/recurrence.js +51 -3
- package/dist/lib/calendar/useDayKey.d.ts +7 -0
- package/dist/lib/calendar/useDayKey.js +43 -0
- package/dist/lib/components/avatar/ContactAvatar.d.ts +6 -2
- package/dist/lib/components/avatar/ContactAvatar.js +37 -8
- package/dist/lib/components/avatar/ContactPhotoAvatar.d.ts +11 -0
- package/dist/lib/components/avatar/ContactPhotoAvatar.js +18 -0
- package/dist/lib/components/forms/CameraCaptureModal.d.ts +17 -0
- package/dist/lib/components/forms/CameraCaptureModal.js +88 -0
- package/dist/lib/components/forms/ImageEditBadge.d.ts +28 -0
- package/dist/lib/components/forms/ImageEditBadge.js +62 -0
- package/dist/lib/components/menus/MenuButton.d.ts +78 -0
- package/dist/lib/components/menus/MenuButton.js +219 -0
- package/dist/lib/components/navigation/BottomTabBar.d.ts +3 -1
- package/dist/lib/components/navigation/BottomTabBar.js +22 -3
- package/dist/lib/components/overlays/PopoverPortal.d.ts +2 -1
- package/dist/lib/components/overlays/PopoverPortal.js +3 -2
- package/dist/lib/components/pickers/TimeZonePicker.js +13 -1
- package/dist/lib/contacts/contactsApi.d.ts +5 -2
- package/dist/lib/contacts/contactsApi.js +16 -19
- package/dist/lib/contacts/gravatar.d.ts +14 -3
- package/dist/lib/contacts/gravatar.js +42 -5
- package/dist/lib/contacts/preparePhoto.d.ts +24 -0
- package/dist/lib/contacts/preparePhoto.js +250 -0
- package/dist/lib/contacts/useContactPhotoSrc.d.ts +19 -0
- package/dist/lib/contacts/useContactPhotoSrc.js +153 -0
- package/dist/lib/contacts/vcard.d.ts +3 -0
- package/dist/lib/contacts/vcard.js +59 -7
- package/dist/lib/crypto/escrowKeys.d.ts +8 -0
- package/dist/lib/crypto/escrowKeys.js +20 -1
- package/dist/lib/crypto/messageSecurity.d.ts +7 -4
- package/dist/lib/crypto/messageSecurity.js +23 -5
- package/dist/lib/crypto/passwordUnlock.d.ts +6 -1
- package/dist/lib/crypto/passwordUnlock.js +13 -2
- package/dist/lib/crypto/smime.js +12 -2
- package/dist/lib/mail/compose/composeQuoting.js +3 -2
- package/dist/lib/mail/compose/quotedImages.d.ts +6 -3
- package/dist/lib/mail/compose/quotedImages.js +35 -9
- package/dist/lib/mail/labelsApi.js +2 -2
- package/dist/lib/mail/mailApi.d.ts +8 -5
- package/dist/lib/mail/mailApi.js +73 -39
- package/dist/lib/mail/mailDetailHooks.js +11 -3
- package/dist/lib/mail/mailFilterRulesApi.js +2 -2
- package/dist/lib/mail/mailSignaturesApi.js +2 -2
- package/dist/lib/mail/mailboxImportApi.d.ts +1 -1
- package/dist/lib/mail/mailboxImportApi.js +3 -15
- package/dist/lib/mail/messageBodySanitizer.d.ts +6 -0
- package/dist/lib/mail/messageBodySanitizer.js +44 -1
- package/dist/lib/search/queryGrammar.js +12 -4
- package/dist/lib/search/searchTier3.d.ts +6 -1
- package/dist/lib/search/searchTier3.js +54 -25
- package/dist/lib/tasks/tasksApi.js +3 -3
- package/dist/lib/util/api.d.ts +26 -4
- package/dist/lib/util/api.js +73 -16
- package/dist/lib/util/apiQuery.d.ts +11 -0
- package/dist/lib/util/apiQuery.js +11 -0
- package/dist/lib/util/settleWithLimit.d.ts +5 -0
- package/dist/lib/util/settleWithLimit.js +25 -0
- package/lib/admin/auditLogApi.ts +2 -2
- package/lib/admin/distributionListsApi.ts +2 -2
- package/lib/admin/domainsApi.ts +2 -2
- package/lib/admin/escrowAuditLogApi.ts +2 -2
- package/lib/admin/escrowScopesApi.ts +2 -2
- package/lib/admin/pluginsApi.ts +45 -3
- package/lib/admin/retentionPolicyApi.ts +4 -0
- package/lib/admin/transportRulesApi.ts +2 -2
- package/lib/appearance/preferencesApi.ts +3 -14
- package/lib/auth/session.ts +239 -39
- package/lib/branding/brandingApi.ts +9 -21
- package/lib/calendar/calendarApi.ts +3 -1
- package/lib/calendar/eventSearch.ts +46 -6
- package/lib/calendar/freeBusyApi.ts +14 -6
- package/lib/calendar/recurrence.ts +59 -8
- package/lib/calendar/useDayKey.ts +45 -0
- package/lib/components/avatar/ContactAvatar.tsx +46 -8
- package/lib/components/avatar/ContactPhotoAvatar.tsx +26 -0
- package/lib/components/forms/CameraCaptureModal.tsx +133 -0
- package/lib/components/forms/ImageEditBadge.tsx +121 -0
- package/lib/components/menus/MenuButton.tsx +408 -0
- package/lib/components/navigation/BottomTabBar.tsx +30 -8
- package/lib/components/overlays/PopoverPortal.tsx +3 -2
- package/lib/components/pickers/TimeZonePicker.tsx +17 -1
- package/lib/contacts/contactsApi.ts +18 -19
- package/lib/contacts/gravatar.ts +47 -5
- package/lib/contacts/preparePhoto.ts +273 -0
- package/lib/contacts/useContactPhotoSrc.ts +177 -0
- package/lib/contacts/vcard.ts +60 -7
- package/lib/crypto/escrowKeys.ts +21 -1
- package/lib/crypto/messageSecurity.ts +33 -9
- package/lib/crypto/passwordUnlock.ts +14 -2
- package/lib/crypto/smime.ts +13 -2
- package/lib/mail/compose/composeQuoting.ts +3 -2
- package/lib/mail/compose/quotedImages.ts +39 -9
- package/lib/mail/labelsApi.ts +2 -2
- package/lib/mail/mailApi.ts +96 -39
- package/lib/mail/mailDetailHooks.ts +11 -3
- package/lib/mail/mailFilterRulesApi.ts +2 -2
- package/lib/mail/mailSignaturesApi.ts +2 -2
- package/lib/mail/mailboxImportApi.ts +9 -15
- package/lib/mail/messageBodySanitizer.ts +47 -1
- package/lib/search/queryGrammar.ts +13 -4
- package/lib/search/searchTier3.ts +65 -32
- package/lib/tasks/tasksApi.ts +3 -3
- package/lib/util/api.ts +99 -16
- package/lib/util/apiQuery.ts +13 -0
- package/lib/util/settleWithLimit.ts +25 -0
- 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 {
|
|
7
|
+
import { setContactFavorite } from "../../../../lib/contacts/contactsApi.js";
|
|
8
8
|
import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
|
|
9
|
-
import
|
|
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(
|
|
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
|
|
17
|
-
* when unknown (
|
|
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 {
|
|
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
|
|
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(
|
|
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 {
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
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 (
|
|
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:
|
|
97
|
-
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
saved
|
|
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
|
-
|
|
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
|
-
|
|
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 (`
|
|
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
|
|
19
|
-
* there is a picture of their own, "Remove photo".
|
|
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,
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
7
|
import ContactAvatar from "../../../../lib/components/avatar/ContactAvatar.js";
|
|
8
|
-
import
|
|
9
|
-
import
|
|
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
|
|
12
|
-
* there is a picture of their own, "Remove photo".
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
onReject("
|
|
33
|
+
catch (err) {
|
|
34
|
+
onReject(err instanceof ContactPhotoError ? err.message : "This picture could not be read - choose another one.");
|
|
35
35
|
}
|
|
36
|
-
|
|
37
|
-
|
|
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 }),
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
}
|