@rapidmx/web-client 0.30.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +1 -1
- 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/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 +39 -17
- 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 +1 -1
- 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/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 +26 -12
- 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 +13 -2
- package/dist/lib/admin/pluginsApi.js +8 -0
- 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 +21 -2
- 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
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { addDays } from "date-fns";
|
|
6
|
+
/** Where things sit in the hour grids of the Week, Work Week, Day and Split views: 24 rows of one clock hour each. */
|
|
7
|
+
export const HOUR_HEIGHT_PX = 48;
|
|
8
|
+
export const SLOT_MINUTES = 30;
|
|
9
|
+
export const SLOTS_PER_DAY = (24 * 60) / SLOT_MINUTES;
|
|
10
|
+
/** The least height a block is drawn with, so a very short event can still be seen and clicked. */
|
|
11
|
+
export const MIN_BLOCK_HEIGHT_PX = 16;
|
|
12
|
+
/** Minutes after midnight on the wall clock (the local zone) at `ms`. */
|
|
13
|
+
function wallMinutes(ms) {
|
|
14
|
+
const date = new Date(ms);
|
|
15
|
+
return date.getHours() * 60 + date.getMinutes() + date.getSeconds() / 60;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The start of the `index`th half-hour row of the day beginning at `dayStart`, by the wall clock - the rows are labelled with clock hours, so on the
|
|
19
|
+
* day a daylight-saving change makes 23 or 25 hours long the row labelled 10 AM must still be 10:00, not ten hours after midnight. (A row whose
|
|
20
|
+
* clock time does not exist that day is the next one that does.)
|
|
21
|
+
*/
|
|
22
|
+
export function slotStart(dayStart, index) {
|
|
23
|
+
return new Date(dayStart.getFullYear(), dayStart.getMonth(), dayStart.getDate(), 0, index * SLOT_MINUTES);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Where a block for an event from `startMs` to `endMs` goes in the column of the day beginning at `dayStart`: a multi-day event is clipped to the day,
|
|
27
|
+
* and (`wholeDay`, for an all-day event) fills it. Positioned by wall-clock minute, like the rows, so a block lines up with its hour on a 23 or 25
|
|
28
|
+
* hour day too.
|
|
29
|
+
*/
|
|
30
|
+
export function blockSpan(startMs, endMs, dayStart, wholeDay = false) {
|
|
31
|
+
const dayEnd = addDays(dayStart, 1).getTime();
|
|
32
|
+
const from = wholeDay || startMs < dayStart.getTime() ? 0 : wallMinutes(startMs);
|
|
33
|
+
const to = wholeDay || endMs >= dayEnd ? 24 * 60 : wallMinutes(endMs);
|
|
34
|
+
return {
|
|
35
|
+
top: (from / 60) * HOUR_HEIGHT_PX,
|
|
36
|
+
height: Math.max(((to - from) / 60) * HOUR_HEIGHT_PX, MIN_BLOCK_HEIGHT_PX),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Splits a column's width between blocks that overlap in time, as Outlook and Google Calendar do, so none is hidden behind another: blocks that
|
|
41
|
+
* overlap (directly or through a chain of others) form a group, and within it each takes the first lane that is free when it starts. Every block of a
|
|
42
|
+
* group gets the group's lane count; a block that overlaps nothing is alone in one lane (the whole width). Results are in the order of `spans`.
|
|
43
|
+
*/
|
|
44
|
+
export function layoutLanes(spans) {
|
|
45
|
+
const lanes = spans.map(() => ({ lane: 0, lanes: 1 }));
|
|
46
|
+
const order = spans.map((_, index) => index).sort((a, b) => spans[a].top - spans[b].top || spans[b].height - spans[a].height);
|
|
47
|
+
let group = [];
|
|
48
|
+
let groupEnd = Number.NEGATIVE_INFINITY;
|
|
49
|
+
let laneEnds = [];
|
|
50
|
+
const closeGroup = () => {
|
|
51
|
+
for (const index of group) {
|
|
52
|
+
lanes[index].lanes = laneEnds.length;
|
|
53
|
+
}
|
|
54
|
+
group = [];
|
|
55
|
+
laneEnds = [];
|
|
56
|
+
};
|
|
57
|
+
for (const index of order) {
|
|
58
|
+
const { top, height } = spans[index];
|
|
59
|
+
if (top >= groupEnd) {
|
|
60
|
+
closeGroup();
|
|
61
|
+
groupEnd = Number.NEGATIVE_INFINITY;
|
|
62
|
+
}
|
|
63
|
+
let lane = laneEnds.findIndex((end) => end <= top);
|
|
64
|
+
if (lane === -1) {
|
|
65
|
+
lane = laneEnds.length;
|
|
66
|
+
laneEnds.push(top + height);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
laneEnds[lane] = top + height;
|
|
70
|
+
}
|
|
71
|
+
lanes[index].lane = lane;
|
|
72
|
+
group.push(index);
|
|
73
|
+
groupEnd = Math.max(groupEnd, top + height);
|
|
74
|
+
}
|
|
75
|
+
closeGroup();
|
|
76
|
+
return lanes;
|
|
77
|
+
}
|
|
78
|
+
/** The horizontal placement of a block in its column: the whole width less a 2px margin, or its lane's share of it. */
|
|
79
|
+
export function laneStyle({ lane, lanes }) {
|
|
80
|
+
return lanes === 1
|
|
81
|
+
? { left: 2, right: 2 }
|
|
82
|
+
: { left: `calc(${(lane / lanes) * 100}% + 2px)`, width: `calc(${100 / lanes}% - 4px)` };
|
|
83
|
+
}
|
|
@@ -92,7 +92,7 @@ export default function CalendarShell({ userUid, authServerUrl, impersonating, i
|
|
|
92
92
|
// A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else
|
|
93
93
|
// for a mailbox-less caller to do here yet, so the icon rail/header don't render at all.
|
|
94
94
|
if (userUid && status === "ready" && mailboxes.length === 0) {
|
|
95
|
-
return _jsx(MailboxProvisioning, {});
|
|
95
|
+
return _jsx(MailboxProvisioning, { impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl });
|
|
96
96
|
}
|
|
97
97
|
let inner = null;
|
|
98
98
|
if (userUid && status === "checking") {
|
|
@@ -11,8 +11,11 @@ const MS_PER_MINUTE = 60000;
|
|
|
11
11
|
* event lasting an hour from there.
|
|
12
12
|
*/
|
|
13
13
|
export function nextHalfHour(now) {
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
// Rounded on the local clock: a zone at :30 or :45 from UTC (India, Nepal, Chatham) has its half hours mid-way between the epoch's.
|
|
15
|
+
const next = new Date(now);
|
|
16
|
+
const minutes = next.getMinutes() + (next.getSeconds() * 1000 + next.getMilliseconds()) / MS_PER_MINUTE;
|
|
17
|
+
next.setMinutes(Math.ceil(minutes / 30) * 30, 0, 0);
|
|
18
|
+
return next;
|
|
16
19
|
}
|
|
17
20
|
/** `HH:mm` (24 hour) as a 12 hour clock reading: "13:05" is "1:05 PM". */
|
|
18
21
|
export function formatTimeOfDay(time) {
|
|
@@ -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;
|