@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
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import type { Editor } from "@tiptap/react";
|
|
3
|
+
/** What a typed link target is stored as. One without a scheme (`example.com`) would be a relative link, which readers' clients drop as dead text: a bare
|
|
4
|
+
* email address becomes `mailto:`, anything else that is not a path or an anchor `https://`. A target with a scheme is kept as it is. */
|
|
5
|
+
export declare function linkHref(typed: string): string;
|
|
3
6
|
export interface ComposeToolbarProps {
|
|
4
7
|
/** `null` before the editor has mounted client-side (see `RichTextEditor`'s `immediatelyRender: false`
|
|
5
8
|
* doc comment) — every button is disabled in that state rather than the toolbar rendering nothing, so
|
|
@@ -6,6 +6,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
import { Suspense, lazy, useRef, useState } from "react";
|
|
7
7
|
import { BsArrowClockwise, BsArrowCounterclockwise, BsEraser, BsHighlighter, BsImage, BsIndent, BsJustify, BsLink45Deg, BsListOl, BsListUl, BsPaletteFill, BsTable, BsTextCenter, BsTextLeft, BsTextRight, BsTypeBold, BsTypeItalic, BsTypeStrikethrough, BsTypeUnderline, BsUnindent, } from "react-icons/bs";
|
|
8
8
|
import GifPicker from "./GifPicker.js";
|
|
9
|
+
/** What a typed link target is stored as. One without a scheme (`example.com`) would be a relative link, which readers' clients drop as dead text: a bare
|
|
10
|
+
* email address becomes `mailto:`, anything else that is not a path or an anchor `https://`. A target with a scheme is kept as it is. */
|
|
11
|
+
export function linkHref(typed) {
|
|
12
|
+
const target = typed.trim();
|
|
13
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || /^[/#]/.test(target)) {
|
|
14
|
+
return target;
|
|
15
|
+
}
|
|
16
|
+
return /^[^\s@/]+@[^\s@/]+\.[^\s@/]+$/.test(target) ? `mailto:${target}` : `https://${target}`;
|
|
17
|
+
}
|
|
9
18
|
/** Loaded when the picker is first opened: the emoji list is a half-megabyte JSON file that nothing else needs. */
|
|
10
19
|
const EmojiPicker = lazy(() => import("./EmojiPicker.js"));
|
|
11
20
|
const FONT_FAMILIES = [
|
|
@@ -73,7 +82,7 @@ export default function ComposeToolbar({ editor, onUploadImage }) {
|
|
|
73
82
|
run((ed) => ed.chain().focus().extendMarkRange("link").unsetLink().run());
|
|
74
83
|
}
|
|
75
84
|
else {
|
|
76
|
-
run((ed) => ed.chain().focus().extendMarkRange("link").setLink({ href: linkUrl
|
|
85
|
+
run((ed) => ed.chain().focus().extendMarkRange("link").setLink({ href: linkHref(linkUrl) }).run());
|
|
77
86
|
}
|
|
78
87
|
setOpenPopup(null);
|
|
79
88
|
}
|
|
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
6
6
|
import { useEffect, useRef, useState } from "react";
|
|
7
7
|
import { HiOutlineArrowsPointingIn, HiOutlineArrowsPointingOut, HiOutlineArrowTopRightOnSquare, HiOutlineChevronDown, HiOutlineIdentification, HiOutlineLockClosed, HiOutlineMinus, HiOutlinePaperClip, HiOutlineTrash, HiOutlineXMark, } from "react-icons/hi2";
|
|
8
8
|
import { ApiRequestError } from "../../../../../lib/util/api.js";
|
|
9
|
-
import { assembleDraft, attachmentContentUrl, createDraft, deleteMessage, getMailbox, getMessage, listFolders, listMailboxes, deleteAttachment, uploadAttachment, } from "../../../../../lib/mail/mailApi.js";
|
|
9
|
+
import { assembleDraft, attachmentContentUrl, createDraft, deleteMessage, forgetDraftImages, getMailbox, getMessage, listFolders, listMailboxes, deleteAttachment, uploadAttachment, } from "../../../../../lib/mail/mailApi.js";
|
|
10
10
|
import { listMailSignatures } from "../../../../../lib/mail/mailSignaturesApi.js";
|
|
11
11
|
import { buildComposeBodyHtml } from "../../../../../lib/mail/compose/composeQuoting.js";
|
|
12
12
|
import { peekMailboxWritability, useMailboxWritability } from "../writableMailboxes.js";
|
|
@@ -223,6 +223,13 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
|
|
|
223
223
|
/** Attachment/inline-image uploads on the wire, and who is waiting for them to finish (Send pressed meanwhile). */
|
|
224
224
|
const uploadsInFlightRef = useRef(0);
|
|
225
225
|
const uploadWaitersRef = useRef([]);
|
|
226
|
+
/** How many uploads have failed so far: a Send that waited on uploads compares it to see whether one of them did. */
|
|
227
|
+
const uploadFailuresRef = useRef(0);
|
|
228
|
+
/** Set while a Send waits for uploads: Close and Discard call it to cancel that send. */
|
|
229
|
+
const cancelUploadWaitRef = useRef(null);
|
|
230
|
+
/** The draft a plaintext save was last sent for, so a message that turns out to need encryption knows a copy may be on the server. */
|
|
231
|
+
const plaintextRequestedRef = useRef(null);
|
|
232
|
+
const [uploadsActive, setUploadsActive] = useState(false);
|
|
226
233
|
/** The latest save request, if any - each save is chained after the one before it. Always settles (never
|
|
227
234
|
* rejects), to the saved message, or `undefined` when that save failed. */
|
|
228
235
|
const saveInFlightRef = useRef(Promise.resolve(undefined));
|
|
@@ -479,6 +486,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
|
|
|
479
486
|
const saved = await saveInFlightRef.current;
|
|
480
487
|
// Once it's out of `supersededDraftsRef` a landing save can't update `superseded.version` any more.
|
|
481
488
|
const version = saved?.uid === superseded.uid ? saved.version : superseded.version;
|
|
489
|
+
forgetDraftImages(superseded.uid);
|
|
482
490
|
try {
|
|
483
491
|
await deleteMessage(superseded.uid, version, client);
|
|
484
492
|
}
|
|
@@ -516,7 +524,8 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
|
|
|
516
524
|
* the old draft is deleted only once the new one has been created. Recipients, subject, and body are
|
|
517
525
|
* kept. */
|
|
518
526
|
function handleFromChange(nextMailboxUid) {
|
|
519
|
-
|
|
527
|
+
// An upload still on the wire counts: it would land on the draft this switch supersedes.
|
|
528
|
+
if (nextMailboxUid === mailboxUid || hasUploads || uploadsInFlightRef.current > 0) {
|
|
520
529
|
return;
|
|
521
530
|
}
|
|
522
531
|
if (draft) {
|
|
@@ -590,12 +599,13 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
|
|
|
590
599
|
uploadStarted();
|
|
591
600
|
try {
|
|
592
601
|
// An inline part with a Content-ID of its own: without one the server has nothing to point the message's `cid:` reference at.
|
|
593
|
-
const attachment = await uploadAttachment(draft.uid, file, { inline: true });
|
|
602
|
+
const attachment = await uploadAttachment(draft.uid, file, { inline: true }, client);
|
|
594
603
|
setHasUploads(true);
|
|
595
604
|
void refreshDraftVersion(draft.uid);
|
|
596
605
|
return attachmentContentUrl(attachment.uid);
|
|
597
606
|
}
|
|
598
607
|
catch (err) {
|
|
608
|
+
uploadFailuresRef.current += 1;
|
|
599
609
|
setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload image.");
|
|
600
610
|
return null;
|
|
601
611
|
}
|
|
@@ -606,10 +616,12 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
|
|
|
606
616
|
/** Send waits for every upload started before it: see `submit()`. */
|
|
607
617
|
function uploadStarted() {
|
|
608
618
|
uploadsInFlightRef.current += 1;
|
|
619
|
+
setUploadsActive(true);
|
|
609
620
|
}
|
|
610
621
|
function uploadFinished() {
|
|
611
622
|
uploadsInFlightRef.current -= 1;
|
|
612
623
|
if (uploadsInFlightRef.current === 0) {
|
|
624
|
+
setUploadsActive(false);
|
|
613
625
|
for (const resolve of uploadWaitersRef.current.splice(0)) {
|
|
614
626
|
resolve();
|
|
615
627
|
}
|
|
@@ -645,13 +657,14 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
|
|
|
645
657
|
try {
|
|
646
658
|
for (const file of files) {
|
|
647
659
|
try {
|
|
648
|
-
const attachment = await uploadAttachment(draft.uid, file);
|
|
660
|
+
const attachment = await uploadAttachment(draft.uid, file, {}, client);
|
|
649
661
|
setHasUploads(true);
|
|
650
662
|
setAttachments((prev) => [...prev, attachment]);
|
|
651
663
|
void refreshDraftVersion(draft.uid);
|
|
652
664
|
}
|
|
653
665
|
catch (err) {
|
|
654
666
|
allAttached = false;
|
|
667
|
+
uploadFailuresRef.current += 1;
|
|
655
668
|
setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
|
|
656
669
|
}
|
|
657
670
|
}
|
|
@@ -769,7 +782,25 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
|
|
|
769
782
|
try {
|
|
770
783
|
if (uploadsInFlightRef.current > 0) {
|
|
771
784
|
setWaitingForUploads(true);
|
|
772
|
-
|
|
785
|
+
const failuresBefore = uploadFailuresRef.current;
|
|
786
|
+
let cancelled = false;
|
|
787
|
+
await new Promise((resolve) => {
|
|
788
|
+
uploadWaitersRef.current.push(resolve);
|
|
789
|
+
cancelUploadWaitRef.current = () => {
|
|
790
|
+
cancelled = true;
|
|
791
|
+
resolve();
|
|
792
|
+
};
|
|
793
|
+
});
|
|
794
|
+
cancelUploadWaitRef.current = null;
|
|
795
|
+
// Close or Discard was pressed meanwhile: the message is not to be sent.
|
|
796
|
+
if (cancelled) {
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
// An attachment the sender asked for did not arrive: say so rather than sending the message without it.
|
|
800
|
+
if (uploadFailuresRef.current > failuresBefore) {
|
|
801
|
+
setSendError("An attachment could not be uploaded, so the message was not sent. Remove it or try again.");
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
773
804
|
// The editor puts an image it has just uploaded into the message a moment after the upload resolves: let the window catch up.
|
|
774
805
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
775
806
|
setWaitingForUploads(false);
|
|
@@ -876,6 +907,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
|
|
|
876
907
|
setSaveStatus("saved");
|
|
877
908
|
return { message: before };
|
|
878
909
|
}
|
|
910
|
+
plaintextRequestedRef.current = target.uid;
|
|
879
911
|
try {
|
|
880
912
|
const saved = await assembleDraft(target.uid, {
|
|
881
913
|
to: parseAddresses(current.to),
|
|
@@ -924,6 +956,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
|
|
|
924
956
|
const version = Math.max(current.version, saved?.uid === current.uid ? saved.version : 0);
|
|
925
957
|
try {
|
|
926
958
|
await deleteMessage(current.uid, version, client);
|
|
959
|
+
forgetDraftImages(current.uid);
|
|
927
960
|
}
|
|
928
961
|
catch (err) {
|
|
929
962
|
if (!(err instanceof ApiRequestError) || (err.status !== 404 && err.status !== 409)) {
|
|
@@ -936,6 +969,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
|
|
|
936
969
|
throw getErr;
|
|
937
970
|
});
|
|
938
971
|
if (!fresh) {
|
|
972
|
+
forgetDraftImages(current.uid);
|
|
939
973
|
return;
|
|
940
974
|
}
|
|
941
975
|
// The version changed because another window sent or scheduled it - that message isn't ours to delete.
|
|
@@ -943,6 +977,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
|
|
|
943
977
|
throw new ApiRequestError(NO_LONGER_A_DRAFT_MESSAGE, 409);
|
|
944
978
|
}
|
|
945
979
|
await deleteMessage(fresh.uid, fresh.version, client);
|
|
980
|
+
forgetDraftImages(current.uid);
|
|
946
981
|
}
|
|
947
982
|
}
|
|
948
983
|
/**
|
|
@@ -951,6 +986,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
|
|
|
951
986
|
* deleted, and a delete that fails keeps the window open with the error.
|
|
952
987
|
*/
|
|
953
988
|
async function discardNow() {
|
|
989
|
+
cancelUploadWaitRef.current?.();
|
|
954
990
|
finishedRef.current = true;
|
|
955
991
|
pendingSaveRef.current = false;
|
|
956
992
|
setClosePrompt(null);
|
|
@@ -990,6 +1026,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
|
|
|
990
1026
|
* *unknown* (settings loading or failed, a lookup pending) is not a reason to hold the window: it is saved like any
|
|
991
1027
|
* other message. A failed save keeps the window open with Discard / Keep editing. */
|
|
992
1028
|
async function handleClose() {
|
|
1029
|
+
cancelUploadWaitRef.current?.();
|
|
993
1030
|
if (!hasUserContent) {
|
|
994
1031
|
void discardNow();
|
|
995
1032
|
return;
|
|
@@ -1084,10 +1121,13 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
|
|
|
1084
1121
|
* uid, so attachments and the folder are undisturbed), chained after any save still on the wire. Best effort - a failure is reported once. */
|
|
1085
1122
|
function scrubSavedDraft(target) {
|
|
1086
1123
|
lastSavedRef.current = null;
|
|
1124
|
+
plaintextRequestedRef.current = null;
|
|
1087
1125
|
setSaveStatus("idle");
|
|
1088
1126
|
const previous = saveInFlightRef.current;
|
|
1089
1127
|
const scrub = (async () => {
|
|
1090
1128
|
await previous;
|
|
1129
|
+
// A save that was on the wire has landed by now and recorded itself as the last one saved - which the scrub just replaced.
|
|
1130
|
+
lastSavedRef.current = null;
|
|
1091
1131
|
try {
|
|
1092
1132
|
// Its answer (the new version) is what a later delete waits for through `saveInFlightRef`, like any save's.
|
|
1093
1133
|
return await assembleDraft(target.uid, { to: [], cc: [], bcc: [], subject: "", html: "" }, client);
|
|
@@ -1106,7 +1146,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
|
|
|
1106
1146
|
}
|
|
1107
1147
|
// Only a message that was really saved (`lastSavedRef` names its draft) has a plaintext copy to replace.
|
|
1108
1148
|
useEffect(() => {
|
|
1109
|
-
if (encryptionDecided && draft && lastSavedRef.current?.startsWith(`${draft.uid}:`)) {
|
|
1149
|
+
if (encryptionDecided && draft && (lastSavedRef.current?.startsWith(`${draft.uid}:`) || plaintextRequestedRef.current === draft.uid)) {
|
|
1110
1150
|
scrubSavedDraft(draft);
|
|
1111
1151
|
}
|
|
1112
1152
|
}, [encryptionDecided, draft?.uid]);
|
|
@@ -1223,6 +1263,9 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
|
|
|
1223
1263
|
// isn't mistaken for an edit (and autosaved) just because the editor was focused or clicked.
|
|
1224
1264
|
/** The quote has been added: unless the reader has typed something already, that is still exactly what compose seeded. */
|
|
1225
1265
|
function handleQuoteAppended(normalized) {
|
|
1266
|
+
// Added for good: an editor mounted again (after a minimize and restore) is seeded from the body that already holds the quote, and
|
|
1267
|
+
// must not be handed it a second time.
|
|
1268
|
+
setSeededWithoutQuote(false);
|
|
1226
1269
|
const latest = latestRef.current;
|
|
1227
1270
|
if (latest.html === latest.seededHtml) {
|
|
1228
1271
|
setHtml(normalized);
|
|
@@ -1247,8 +1290,10 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
|
|
|
1247
1290
|
// dispatcher the focus is in a compose window at all). They do what the Send and Close buttons and autosave do, under the same conditions -
|
|
1248
1291
|
// a shortcut pressed while its button would be disabled is consumed and does nothing.
|
|
1249
1292
|
// Send never waits for the encryption settings to load (fail open, see `encryptionRequirement.ts`); it only needs the draft to exist. Pressed
|
|
1250
|
-
// while an earlier press is still being handled it does nothing (`submit()` guards that itself).
|
|
1251
|
-
|
|
1293
|
+
// while an earlier press is still being handled it does nothing (`submit()` guards that itself). A reply or forward waits for its quoted original
|
|
1294
|
+
// (at most `QUOTE_FETCH_TIMEOUT_MS`): sent sooner it would go without the quote, a forward without its content, a Reply All to the sender alone.
|
|
1295
|
+
// Nor before the editor is there (it waits for the signature): sent sooner a reply would go without its quote and signature.
|
|
1296
|
+
const canSend = !!draft && contentReady && !closing && !waitingForUploads && !quotePending;
|
|
1252
1297
|
useShortcut(SHORTCUTS.compose.send, () => void (canSend && submit(false)), { container: windowRef });
|
|
1253
1298
|
useShortcut(SHORTCUTS.compose.saveDraft, () => {
|
|
1254
1299
|
// Autosave's own conditions: never a draft that must not be stored as plaintext.
|
|
@@ -1280,7 +1325,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
|
|
|
1280
1325
|
].join(" "), children: [dropActive && (_jsxs("div", { "data-drop-overlay": "", className: "pointer-events-none absolute inset-0 z-30 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-primary bg-primary/10 text-center text-primary-dark", children: [_jsx("span", { className: "text-base font-semibold", children: "Drop files to attach them" }), _jsx("span", { className: "text-sm", children: "Pictures go into the message where the cursor is" })] })), !inline && !isMobile && (_jsxs(_Fragment, { children: [_jsx("div", { onPointerDown: handleResizeStart("top"), "aria-hidden": "true", className: "absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("left"), "aria-hidden": "true", className: "absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("corner"), role: "separator", "aria-label": "Resize", className: "absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30" })] })), _jsxs("div", { className: ["h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white", inline ? "" : "cursor-pointer"].join(" "), onClick: inline ? undefined : onToggleMinimize, children: [_jsx("span", { id: titleId, className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [inline ? (_jsx(HeaderButton, { label: "Pop out", onClick: () => onPopOut?.(), icon: HiOutlineArrowTopRightOnSquare })) : (_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus })), !inline && !isMobile && (_jsx(HeaderButton, { label: expanded ? "Collapse" : "Expand", onClick: toggleExpanded, icon: expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut })), _jsx(HeaderButton, { label: "Close", onClick: () => void handleClose(), icon: HiOutlineXMark, disabled: closing, shortcut: SHORTCUTS.compose.close })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError || discardError) && (_jsxs("div", { className: "px-3 pt-2 shrink-0 max-h-[45%] overflow-y-auto", children: [folderError && _jsx(Alert, { children: folderError }), draftError && _jsx(Alert, { children: draftError }), sendError && _jsx(Alert, { children: sendError }), attachError && _jsx(Alert, { children: attachError }), discardError && _jsx(Alert, { children: discardError })] })), securityBlock && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: securityBlock.message }), _jsx(Button, { type: "button", onClick: () => void submit(true, blockedScheduleIso), children: securityBlock.overrideLabel })] }) })), fromOptions.length > 1 && (_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-from-${id}`, className: "text-xs text-text-muted shrink-0", children: "From" }), _jsx("select", { id: `compose-from-${id}`, className: `${FIELD_INPUT} disabled:opacity-55`,
|
|
1281
1326
|
// Two or more options only exist once the mailboxes have loaded, and the same answer that lets them list
|
|
1282
1327
|
// (one that isn't known to be view-only) is what picks the default sender, so a sender is always chosen by now.
|
|
1283
|
-
value: mailboxUid, disabled: hasUploads, title: hasUploads ? "The sender can't be changed after adding attachments or images." : undefined, onChange: (e) => handleFromChange(e.target.value), children: fromOptions.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName ? `${mb.displayName} <${mb.primarySmtpAddress}>` : mb.primarySmtpAddress, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-to-${id}`, className: "text-xs text-text-muted shrink-0", children: "To" }), _jsx(RecipientInput, { id: `compose-to-${id}`, label: "To", value: to, mailboxUid: mailboxUid, onChange: setTo, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)), autoFocus: focusToOnMount }), !showCcBcc && (_jsx("button", { type: "button", onClick: () => setShowCcBcc(true), className: "text-xs text-text-muted hover:text-text shrink-0", children: "Cc Bcc" }))] }), showCcBcc && (_jsxs(_Fragment, { children: [_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-cc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Cc" }), _jsx(RecipientInput, { id: `compose-cc-${id}`, label: "Cc", value: cc, mailboxUid: mailboxUid, onChange: setCc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] }), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-bcc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Bcc" }), _jsx(RecipientInput, { id: `compose-bcc-${id}`, label: "Bcc", value: bcc, mailboxUid: mailboxUid, onChange: setBcc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] })] })), (() => {
|
|
1328
|
+
value: mailboxUid, disabled: hasUploads || uploadsActive, title: hasUploads || uploadsActive ? "The sender can't be changed after adding attachments or images." : undefined, onChange: (e) => handleFromChange(e.target.value), children: fromOptions.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName ? `${mb.displayName} <${mb.primarySmtpAddress}>` : mb.primarySmtpAddress, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-to-${id}`, className: "text-xs text-text-muted shrink-0", children: "To" }), _jsx(RecipientInput, { id: `compose-to-${id}`, label: "To", value: to, mailboxUid: mailboxUid, onChange: setTo, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)), autoFocus: focusToOnMount }), !showCcBcc && (_jsx("button", { type: "button", onClick: () => setShowCcBcc(true), className: "text-xs text-text-muted hover:text-text shrink-0", children: "Cc Bcc" }))] }), showCcBcc && (_jsxs(_Fragment, { children: [_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-cc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Cc" }), _jsx(RecipientInput, { id: `compose-cc-${id}`, label: "Cc", value: cc, mailboxUid: mailboxUid, onChange: setCc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] }), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-bcc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Bcc" }), _jsx(RecipientInput, { id: `compose-bcc-${id}`, label: "Bcc", value: bcc, mailboxUid: mailboxUid, onChange: setBcc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] })] })), (() => {
|
|
1284
1329
|
// De-duplicated by the same normalized key `recipientStatuses` itself is keyed by - two
|
|
1285
1330
|
// fields (or one field twice) naming the same mailbox in different casing now resolve to
|
|
1286
1331
|
// one shared status object (see `addressCacheKey()`'s own doc comment), so without this
|
|
@@ -1302,5 +1347,5 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
|
|
|
1302
1347
|
return null;
|
|
1303
1348
|
}
|
|
1304
1349
|
return (_jsx("ul", { className: "flex flex-wrap gap-1.5 px-3 pt-1.5", "aria-label": "Recipient encryption availability", children: knownRecipients.map((status) => (_jsxs("li", { className: `text-xs font-medium py-0.5 px-2 rounded-pill ${status.canEncrypt ? "bg-primary/10 text-primary-dark" : "bg-surface-alt text-text-muted"}`, children: [status.address, " ", status.canEncrypt ? "supports encryption" : "no encryption key found"] }, status.address))) }));
|
|
1305
|
-
})(), _jsx("div", { className: FIELD_ROW, children: _jsx("input", { "aria-label": "Subject", type: "text", placeholder: "Subject", className: FIELD_INPUT, value: subject, autoFocus: focusSubjectOnMount, onChange: (e) => setSubject(e.target.value) }) }), _jsx("div", { className: "flex-1 min-h-0 p-2", children: contentReady && (_jsx(RichTextEditor, { appendHtml: lateQuoteHtml, onAppended: handleQuoteAppended, value: html, onChange: setHtml, fill: true, onUploadImage: handleUploadImage, autoFocusStart: focusBodyOnMount, onInitialized: handleEditorInitialized, editorRef: editorRef })) }), quotePending && (_jsx("p", { role: "status", className: "px-3 pb-1 text-xs text-text-muted", children: "Loading the original message\u2026" })), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 px-3 pb-2", children: attachments.map((attachment) => (_jsxs("li", { className: "inline-flex items-center gap-1 text-xs font-medium py-1 pl-2.5 pr-1 rounded-pill bg-surface-alt text-text-muted", children: [attachment.filename, _jsx("button", { type: "button", "aria-label": `Remove ${attachment.filename}`, title: `Remove ${attachment.filename}`, disabled: removingAttachmentUid === attachment.uid, onClick: () => void handleRemoveAttachment(attachment), className: "inline-flex items-center justify-center rounded-pill p-0.5 hover:bg-border hover:text-text disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-primary", children: _jsx(HiOutlineXMark, { size: 12, "aria-hidden": "true" }) })] }, attachment.uid))) })), _jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: requestReceipt, onChange: (e) => setRequestReceipt(e.target.checked) }), "Request a read receipt"] }), needsUnlockForCrypto && (_jsx("div", { className: "px-3 pb-1", children: _jsxs("button", { type: "button", onClick: handleUnlockForCrypto, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to sign or encrypt this message"] }) })), unlockedKeys?.signingPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: signEnabled, onChange: (e) => setSignEnabled(e.target.checked) }), "Digitally sign this message"] })), unlockedKeys?.encryptionPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: encryptRequested, onChange: (e) => setEncryptRequested(e.target.checked) }), "Encrypt this message"] })), _jsxs("div", { className: "shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border", children: [_jsxs("div", { className: "flex items-center rounded-pill bg-primary text-white overflow-hidden", children: [_jsx("button", { type: "button", onClick: () => void submit(false), title: withHint("Send", SHORTCUTS.compose.send, keyEnv), "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.compose.send, keyEnv), disabled: !
|
|
1350
|
+
})(), _jsx("div", { className: FIELD_ROW, children: _jsx("input", { "aria-label": "Subject", type: "text", placeholder: "Subject", className: FIELD_INPUT, value: subject, autoFocus: focusSubjectOnMount, onChange: (e) => setSubject(e.target.value) }) }), _jsx("div", { className: "flex-1 min-h-0 p-2", children: contentReady && (_jsx(RichTextEditor, { appendHtml: lateQuoteHtml, onAppended: handleQuoteAppended, value: html, onChange: setHtml, fill: true, onUploadImage: handleUploadImage, autoFocusStart: focusBodyOnMount, onInitialized: handleEditorInitialized, editorRef: editorRef })) }), quotePending && (_jsx("p", { role: "status", className: "px-3 pb-1 text-xs text-text-muted", children: "Loading the original message\u2026" })), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 px-3 pb-2", children: attachments.map((attachment) => (_jsxs("li", { className: "inline-flex items-center gap-1 text-xs font-medium py-1 pl-2.5 pr-1 rounded-pill bg-surface-alt text-text-muted", children: [attachment.filename, _jsx("button", { type: "button", "aria-label": `Remove ${attachment.filename}`, title: `Remove ${attachment.filename}`, disabled: removingAttachmentUid === attachment.uid, onClick: () => void handleRemoveAttachment(attachment), className: "inline-flex items-center justify-center rounded-pill p-0.5 hover:bg-border hover:text-text disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-primary", children: _jsx(HiOutlineXMark, { size: 12, "aria-hidden": "true" }) })] }, attachment.uid))) })), _jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: requestReceipt, onChange: (e) => setRequestReceipt(e.target.checked) }), "Request a read receipt"] }), needsUnlockForCrypto && (_jsx("div", { className: "px-3 pb-1", children: _jsxs("button", { type: "button", onClick: handleUnlockForCrypto, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to sign or encrypt this message"] }) })), unlockedKeys?.signingPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: signEnabled, onChange: (e) => setSignEnabled(e.target.checked) }), "Digitally sign this message"] })), unlockedKeys?.encryptionPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: encryptRequested, onChange: (e) => setEncryptRequested(e.target.checked) }), "Encrypt this message"] })), _jsxs("div", { className: "shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border", children: [_jsxs("div", { className: "flex items-center rounded-pill bg-primary text-white overflow-hidden", children: [_jsx("button", { type: "button", onClick: () => void submit(false), title: withHint("Send", SHORTCUTS.compose.send, keyEnv), "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.compose.send, keyEnv), disabled: !canSend, className: "py-1.5 pl-5 pr-3 font-semibold text-sm hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: "Send" }), _jsx("button", { ref: scheduleButtonRef, type: "button", "aria-label": "Send later", "aria-haspopup": "true", "aria-expanded": schedulePickerOpen, disabled: !canSend, onClick: () => setSchedulePickerOpen((o) => !o), className: "py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: _jsx(HiOutlineChevronDown, { size: 14 }) })] }), schedulePickerOpen && (_jsx(ScheduleSendPicker, { anchorRef: scheduleButtonRef, onClose: () => setSchedulePickerOpen(false), onSchedule: handleScheduleSend })), _jsxs("label", { "aria-label": "Attach files", title: "Attach files", className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed", children: [_jsx(HiOutlinePaperClip, { size: 18 }), _jsx("input", { type: "file", multiple: true, disabled: !draft, onChange: handleFilesSelected, className: "sr-only" })] }), _jsx("button", { type: "button", "aria-label": "Attach my contact card", title: ownCardAttached ? "Your contact card is attached" : "Attach my contact card", "aria-pressed": ownCardAttached, disabled: !draft || !mailbox || ownCardAttached, onClick: () => void handleAttachOwnCard(), className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:not-disabled:bg-surface-alt hover:not-disabled:text-text disabled:opacity-40 disabled:cursor-not-allowed", children: _jsx(HiOutlineIdentification, { size: 18 }) }), _jsx("span", { role: "status", "aria-live": "polite", className: "ml-auto text-xs text-text-muted", children: waitingForUploads ? "Waiting for attachments to finish uploading…" : SAVE_STATUS_LABEL[saveStatus] }), _jsx("button", { type: "button", "aria-label": "Discard draft", title: "Discard draft", onClick: handleDiscard, disabled: closing, className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text disabled:opacity-40 disabled:cursor-not-allowed", children: _jsx(HiOutlineTrash, { size: 18 }) })] })] })] }), discardModal] }));
|
|
1306
1351
|
}
|
|
@@ -24,13 +24,19 @@ export default function GifPicker({ anchorRef, onSelect, onClose }) {
|
|
|
24
24
|
useEffect(() => {
|
|
25
25
|
setLoading(true);
|
|
26
26
|
setError(null);
|
|
27
|
+
// A search that was still on the wire when the query changed (or the popup closed) is not heard: a slow answer to an earlier query
|
|
28
|
+
// must not replace the results of the one now shown.
|
|
29
|
+
let stale = false;
|
|
27
30
|
const handle = setTimeout(() => {
|
|
28
31
|
searchGifs(query)
|
|
29
|
-
.then(setGifs)
|
|
30
|
-
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load GIFs."))
|
|
31
|
-
.finally(() => setLoading(false));
|
|
32
|
+
.then((found) => !stale && setGifs(found))
|
|
33
|
+
.catch((err) => !stale && setError(err instanceof ApiRequestError ? err.message : "Could not load GIFs."))
|
|
34
|
+
.finally(() => !stale && setLoading(false));
|
|
32
35
|
}, SEARCH_DEBOUNCE_MS);
|
|
33
|
-
return () =>
|
|
36
|
+
return () => {
|
|
37
|
+
stale = true;
|
|
38
|
+
clearTimeout(handle);
|
|
39
|
+
};
|
|
34
40
|
}, [query]);
|
|
35
41
|
return (_jsxs(PopoverPortal, { anchorRef: anchorRef, onClose: onClose, width: WIDTH, height: HEIGHT, "aria-label": "Insert GIF", children: [_jsx("div", { className: "p-2 border-b border-border shrink-0", children: _jsx("input", { type: "text", autoFocus: true, "aria-label": "Search GIFs", placeholder: "Search GIFs", value: query, onChange: (e) => setQuery(e.target.value), className: "w-full text-sm py-1.5 px-2 border border-border rounded-sm bg-surface focus:outline-none focus:border-primary" }) }), _jsx("div", { className: "flex-1 overflow-y-auto p-2", children: error ? (_jsx("p", { className: "text-xs text-danger", children: error })) : loading ? (_jsx("p", { className: "text-xs text-text-muted", children: "Loading\u2026" })) : gifs.length === 0 ? (_jsx("p", { className: "text-xs text-text-muted", children: "No GIFs found." })) : (_jsx("div", { className: "grid grid-cols-2 gap-1.5", children: gifs.map((gif) => (_jsx("button", { type: "button", title: gif.title, onClick: () => onSelect(gif.url), className: "rounded-sm overflow-hidden hover:opacity-80", children: _jsx("img", { src: gif.previewUrl, alt: gif.title, className: "w-full h-20 object-cover" }) }, gif.id))) })) })] }));
|
|
36
42
|
}
|
|
@@ -44,7 +44,8 @@ function dropdownPosition(anchor) {
|
|
|
44
44
|
*/
|
|
45
45
|
export default function RecipientInput({ id, label, value, onChange, onBlur, onCommit, mailboxUid, fetchSuggestions = fetchRecipientSuggestions, debounceMs = RECIPIENT_SUGGESTION_DEBOUNCE_MS, disabled, autoFocus, onEdit, initialPending = "", hideChip, placeholder, ariaLabel, listLabel, className, dropdownZIndex, }) {
|
|
46
46
|
const [pending, setPending] = useState(initialPending);
|
|
47
|
-
|
|
47
|
+
// With the query they were loaded for: a list for earlier text is not offered (Enter or Tab would put somebody else in place of what was typed).
|
|
48
|
+
const [suggestionList, setSuggestionList] = useState({ query: "", items: [] });
|
|
48
49
|
const [open, setOpen] = useState(false);
|
|
49
50
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
50
51
|
const [position, setPosition] = useState(null);
|
|
@@ -69,24 +70,24 @@ export default function RecipientInput({ id, label, value, onChange, onBlur, onC
|
|
|
69
70
|
}
|
|
70
71
|
}, [pending, pendingActive]);
|
|
71
72
|
const committedAddresses = new Set(chips.map((chip) => parseRecipient(chip).address.toLowerCase()));
|
|
72
|
-
const visible =
|
|
73
|
+
const visible = (suggestionList.query === query ? suggestionList.items : []).filter((suggestion) => !committedAddresses.has(suggestion.address.toLowerCase()));
|
|
73
74
|
const expanded = open && visible.length > 0 && query.length >= RECIPIENT_SUGGESTION_MIN_QUERY_LENGTH;
|
|
74
75
|
const active = Math.min(activeIndex, visible.length - 1);
|
|
75
76
|
useEffect(() => {
|
|
76
77
|
if (query.length < RECIPIENT_SUGGESTION_MIN_QUERY_LENGTH) {
|
|
77
|
-
|
|
78
|
+
setSuggestionList({ query: "", items: [] });
|
|
78
79
|
return;
|
|
79
80
|
}
|
|
80
81
|
const controller = new AbortController();
|
|
81
82
|
const timer = setTimeout(() => {
|
|
82
83
|
fetchRef.current(query, { mailboxUid, signal: controller.signal }).then((result) => {
|
|
83
84
|
if (!controller.signal.aborted) {
|
|
84
|
-
|
|
85
|
+
setSuggestionList({ query, items: result });
|
|
85
86
|
setActiveIndex(0);
|
|
86
87
|
}
|
|
87
88
|
}, () => {
|
|
88
89
|
if (!controller.signal.aborted) {
|
|
89
|
-
|
|
90
|
+
setSuggestionList({ query: "", items: [] });
|
|
90
91
|
}
|
|
91
92
|
});
|
|
92
93
|
}, debounceMs);
|
|
@@ -139,7 +140,7 @@ export default function RecipientInput({ id, label, value, onChange, onBlur, onC
|
|
|
139
140
|
const next = committed.join(", ");
|
|
140
141
|
setPending("");
|
|
141
142
|
setOpen(false);
|
|
142
|
-
|
|
143
|
+
setSuggestionList({ query: "", items: [] });
|
|
143
144
|
onChange(next);
|
|
144
145
|
onEdit?.(committed, "");
|
|
145
146
|
onCommit?.(next);
|
|
@@ -204,7 +205,8 @@ export default function RecipientInput({ id, label, value, onChange, onBlur, onC
|
|
|
204
205
|
return (_jsxs("div", { ref: anchorRef, className: `flex-1 min-w-0 flex flex-wrap items-center gap-1${className ? ` ${className}` : ""}`, children: [drawn.length > 0 && (_jsx("ul", { role: "list", "aria-label": listLabel ?? `${label} recipients`, className: "contents", children: drawn.map(({ chip, index }) => {
|
|
205
206
|
const recipient = parseRecipient(chip);
|
|
206
207
|
const valid = isValidRecipientAddress(recipient.address);
|
|
207
|
-
|
|
208
|
+
// A name that is itself an address (`"billing@bank.com" <x@evil.tld>`) would hide the one it is really sent to: both are shown.
|
|
209
|
+
const name = /[@@﹫]/.test(recipient.displayName ?? "") ? `${recipient.displayName} <${recipient.address}>` : recipient.displayName || recipient.address;
|
|
208
210
|
return (_jsxs("li", { title: chip, className: `flex items-center gap-1 max-w-full min-w-0 rounded-pill border pl-2 pr-0.5 py-px text-xs ${valid ? "bg-surface-alt border-border text-text" : "bg-danger-bg border-danger text-danger"}`, children: [_jsx("span", { className: "truncate", children: name }), !valid && _jsx("span", { className: "sr-only", children: " (not a valid email address)" }), _jsx("button", { type: "button", tabIndex: -1, "aria-label": `Remove ${recipient.address}`, disabled: disabled, onMouseDown: (e) => e.preventDefault(), onClick: () => removeChip(index), className: "shrink-0 w-4 h-4 flex items-center justify-center rounded-pill text-text-muted hover:bg-border hover:text-text", children: _jsx(HiOutlineXMark, { size: 10 }) })] }, `${index}-${chip}`));
|
|
209
211
|
}) })), _jsx("input", { ref: inputRef, id: id, type: "text", role: "combobox", autoComplete: "off", "aria-autocomplete": "list", "aria-label": ariaLabel, placeholder: placeholder, "aria-expanded": expanded, "aria-controls": listboxId, "aria-activedescendant": expanded ? optionId(active) : undefined, disabled: disabled, autoFocus: autoFocus, className: "flex-1 min-w-[6rem] text-sm bg-transparent outline-none py-0.5", value: inputValue, onChange: handleInputChange, onKeyDown: handleKeyDown, onBlur: handleBlur }), _jsx("span", { className: "sr-only", "aria-live": "polite", children: expanded ? `${visible.length} ${visible.length === 1 ? "suggestion" : "suggestions"}` : "" }), expanded &&
|
|
210
212
|
position &&
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/** Saves a compose window's unsaved edits (or waits for a save already on the wire), resolving to `false` when
|
|
2
2
|
* something the window holds is still unsaved afterwards (any other value counts as saved). Never rejects. */
|
|
3
3
|
export type ComposeFlush = () => Promise<unknown>;
|
|
4
|
+
/**
|
|
5
|
+
* Registers something to run the moment the app starts signing out (explicitly, or forced by another tab): a cache that holds message content,
|
|
6
|
+
* which must not outlive the session it was read in. Called once, when the module that owns the cache is loaded.
|
|
7
|
+
*/
|
|
8
|
+
export declare function onSigningOut(cleanup: () => void): void;
|
|
4
9
|
/**
|
|
5
10
|
* Marks the app as signing out (explicitly, or forced by a sign-out in another tab), so compose windows stop
|
|
6
11
|
* asking the browser to confirm leaving the page - the "Leave site?" prompt would otherwise let the user cancel
|
|
7
12
|
* the sign-out's own navigation and stay on a page whose session is being ended. Call it before flushing drafts
|
|
8
|
-
* and navigating. `clearSigningOut()` undoes it (a sign-out that didn't leave the page after all).
|
|
13
|
+
* and navigating. `clearSigningOut()` undoes it (a sign-out that didn't leave the page after all). It also runs what was registered
|
|
14
|
+
* with `onSigningOut()`.
|
|
9
15
|
*/
|
|
10
16
|
export declare function markSigningOut(): void;
|
|
11
17
|
/** Undoes `markSigningOut()`. */
|
|
@@ -3,15 +3,27 @@
|
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
const flushers = new Set();
|
|
6
|
+
const signOutCleanups = new Set();
|
|
6
7
|
let signingOut = false;
|
|
8
|
+
/**
|
|
9
|
+
* Registers something to run the moment the app starts signing out (explicitly, or forced by another tab): a cache that holds message content,
|
|
10
|
+
* which must not outlive the session it was read in. Called once, when the module that owns the cache is loaded.
|
|
11
|
+
*/
|
|
12
|
+
export function onSigningOut(cleanup) {
|
|
13
|
+
signOutCleanups.add(cleanup);
|
|
14
|
+
}
|
|
7
15
|
/**
|
|
8
16
|
* Marks the app as signing out (explicitly, or forced by a sign-out in another tab), so compose windows stop
|
|
9
17
|
* asking the browser to confirm leaving the page - the "Leave site?" prompt would otherwise let the user cancel
|
|
10
18
|
* the sign-out's own navigation and stay on a page whose session is being ended. Call it before flushing drafts
|
|
11
|
-
* and navigating. `clearSigningOut()` undoes it (a sign-out that didn't leave the page after all).
|
|
19
|
+
* and navigating. `clearSigningOut()` undoes it (a sign-out that didn't leave the page after all). It also runs what was registered
|
|
20
|
+
* with `onSigningOut()`.
|
|
12
21
|
*/
|
|
13
22
|
export function markSigningOut() {
|
|
14
23
|
signingOut = true;
|
|
24
|
+
for (const cleanup of signOutCleanups) {
|
|
25
|
+
cleanup();
|
|
26
|
+
}
|
|
15
27
|
}
|
|
16
28
|
/** Undoes `markSigningOut()`. */
|
|
17
29
|
export function clearSigningOut() {
|
|
@@ -18,6 +18,11 @@ import type { EncryptionPolicy } from "../../../../../lib/crypto/keyvaultApi.js"
|
|
|
18
18
|
* reported in `unknown`, so a caller that is about to send can give the loads a moment - and only a caller that finds out later that the
|
|
19
19
|
* message *does* need encryption acts on it (see `ComposeWindow`'s transition rules).
|
|
20
20
|
*
|
|
21
|
+
* That is this function's answer, and it stays fail open for everything that is about a draft or the compose window. The background send
|
|
22
|
+
* (`sendDecision.ts`'s `decideSend()` with `lookupsComplete`) reads `unknown` once its loads are in: a sender who has an encryption key, and
|
|
23
|
+
* whose policy or recipients' keys could not be checked, is blocked with the `lookup-failed` decision (the sender may still send without
|
|
24
|
+
* encryption) rather than sent in the clear on a guess.
|
|
25
|
+
*
|
|
21
26
|
* Why this is safe enough: the requirement only ever protects *this client's* plaintext drafts and sends from being unencrypted when the
|
|
22
27
|
* server's policy says they should be; it is not the enforcement point (the keys are the user's, the server never sees them). A missing
|
|
23
28
|
* policy therefore does not mean "encrypt or refuse" - it means the client cannot tell, and the honest default is the one that works.
|
|
@@ -7,12 +7,23 @@ import { attachmentContentUrl, getMessageRawContent, listAttachments } from "../
|
|
|
7
7
|
import { MAX_QUOTED_IMAGE_BYTES, embedQuotedImages } from "../../../../../lib/mail/compose/quotedImages.js";
|
|
8
8
|
import { EMBEDDED_IMAGE_TYPES, findInlineAttachment, toDataUri } from "../../../../../lib/mail/inlineImages.js";
|
|
9
9
|
import { decodeHeaderText, extractDisplayBody, parseMimeEntity } from "../../../../../lib/crypto/mime.js";
|
|
10
|
+
import { onSigningOut } from "./composeFlushRegistry.js";
|
|
10
11
|
import { parseRecipientList } from "./recipients.js";
|
|
12
|
+
/** The security states whose `protectedHeaders` a signature covers - the only ones Reply All takes recipients from. */
|
|
13
|
+
const TRUSTED_HEADER_STATES = new Set([
|
|
14
|
+
"signed_verified",
|
|
15
|
+
"encrypted_verified",
|
|
16
|
+
"signed_unverified_signer",
|
|
17
|
+
"encrypted_unverified_signer",
|
|
18
|
+
"verified_at_first_open",
|
|
19
|
+
]);
|
|
11
20
|
/** How long a reply waits for the original's body before it goes ahead with the preview - a slow or hung request must not
|
|
12
21
|
* leave the window waiting on it (or, before it opened at once, keep it from opening) indefinitely. */
|
|
13
22
|
export const QUOTE_FETCH_TIMEOUT_MS = 10000;
|
|
14
23
|
/** How long a fetched (or prefetched) body is remembered, so hovering Reply and then clicking it costs one request. */
|
|
15
24
|
export const QUOTE_CACHE_MS = 30000;
|
|
25
|
+
/** How many bodies are remembered at most: each is a whole message body, and a mailbox read for hours would otherwise keep every one it was asked about. */
|
|
26
|
+
const QUOTE_CACHE_MAX = 20;
|
|
16
27
|
const contentCache = new Map();
|
|
17
28
|
/**
|
|
18
29
|
* The server's sanitized HTML body of a message (`GET /mail/messages/:id/content`), or `undefined` when it has none (a
|
|
@@ -38,6 +49,12 @@ function fetchContentHtml(uid) {
|
|
|
38
49
|
clearTimeout(timer);
|
|
39
50
|
}
|
|
40
51
|
})();
|
|
52
|
+
// Whatever has expired goes, and the oldest of the rest once there are too many.
|
|
53
|
+
for (const [key, entry] of contentCache) {
|
|
54
|
+
if (Date.now() - entry.at >= QUOTE_CACHE_MS || contentCache.size >= QUOTE_CACHE_MAX) {
|
|
55
|
+
contentCache.delete(key);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
41
58
|
contentCache.set(uid, { at: Date.now(), promise });
|
|
42
59
|
void promise.then((html) => {
|
|
43
60
|
if (html === undefined && contentCache.get(uid)?.promise === promise) {
|
|
@@ -68,6 +85,7 @@ export function prefetchOriginalMessage(message) {
|
|
|
68
85
|
export function clearOriginalMessageCache() {
|
|
69
86
|
contentCache.clear();
|
|
70
87
|
}
|
|
88
|
+
onSigningOut(clearOriginalMessageCache);
|
|
71
89
|
/** The recipients one address-list header names, RFC 2047-decoded, with their display names. */
|
|
72
90
|
function headerRecipients(value, type) {
|
|
73
91
|
if (!value) {
|
|
@@ -81,7 +99,7 @@ function headerRecipients(value, type) {
|
|
|
81
99
|
* or `undefined` when there is none, it is not a raster image, it is too large or it could not be had. Never rejects. */
|
|
82
100
|
function serverImageResolver(message) {
|
|
83
101
|
let attachments;
|
|
84
|
-
return async (reference) => {
|
|
102
|
+
return async (reference, signal) => {
|
|
85
103
|
try {
|
|
86
104
|
attachments ?? (attachments = listAttachments(message.folderUid, message.uid));
|
|
87
105
|
const attachment = findInlineAttachment(await attachments, reference);
|
|
@@ -89,7 +107,7 @@ function serverImageResolver(message) {
|
|
|
89
107
|
if (!attachment || !type || !EMBEDDED_IMAGE_TYPES.test(type) || attachment.sizeBytes > MAX_QUOTED_IMAGE_BYTES) {
|
|
90
108
|
return undefined;
|
|
91
109
|
}
|
|
92
|
-
const res = await fetch(attachmentContentUrl(attachment.uid), { credentials: "include" });
|
|
110
|
+
const res = await fetch(attachmentContentUrl(attachment.uid), { credentials: "include", signal });
|
|
93
111
|
const bytes = res.ok ? new Uint8Array(await res.arrayBuffer()) : undefined;
|
|
94
112
|
return bytes && bytes.length <= MAX_QUOTED_IMAGE_BYTES ? toDataUri(type, bytes) : undefined;
|
|
95
113
|
}
|
|
@@ -136,7 +154,9 @@ function partImageResolver(parts) {
|
|
|
136
154
|
*/
|
|
137
155
|
export async function loadOriginalMessage(message, security, options = {}) {
|
|
138
156
|
const recovered = security?.text !== undefined ? { text: security.text } : security?.html !== undefined ? { html: security.html } : undefined;
|
|
139
|
-
|
|
157
|
+
// Headers inside an unsigned (or badly signed) encrypted message are whatever its encrypter chose to write, so only a
|
|
158
|
+
// verified signature makes them a source of recipients.
|
|
159
|
+
const protectedHeaders = security && TRUSTED_HEADER_STATES.has(security.state) ? security.protectedHeaders : undefined;
|
|
140
160
|
const protectedRecipients = protectedHeaders
|
|
141
161
|
? [...headerRecipients(protectedHeaders.to, "to"), ...headerRecipients(protectedHeaders.cc, "cc")]
|
|
142
162
|
: [];
|
|
@@ -176,8 +196,11 @@ export async function loadOriginalMessage(message, security, options = {}) {
|
|
|
176
196
|
if (result.body.html !== undefined) {
|
|
177
197
|
const resolver = recovered ? partImageResolver(security?.attachments ?? []) : serverImageResolver(message);
|
|
178
198
|
const html = result.body.html;
|
|
179
|
-
// Bounded like the body's own request: a picture that
|
|
180
|
-
|
|
199
|
+
// Bounded like the body's own request: a picture that has not arrived by then is cancelled and left out of the quote, not waited for -
|
|
200
|
+
// the ones that have arrived stay.
|
|
201
|
+
const controller = new AbortController();
|
|
202
|
+
const timer = setTimeout(() => controller.abort(), QUOTE_FETCH_TIMEOUT_MS);
|
|
203
|
+
result.body = { html: await embedQuotedImages(html, resolver, controller.signal).finally(() => clearTimeout(timer)) };
|
|
181
204
|
}
|
|
182
205
|
return result;
|
|
183
206
|
}
|
|
@@ -204,7 +204,7 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
|
|
|
204
204
|
// `mailboxes.length` directly (not `!mailboxUid`) since `mailboxUid` is legitimately undefined in
|
|
205
205
|
// aggregate mode even with mailboxes present.
|
|
206
206
|
if (userUid && status === "ready" && mailboxes.length === 0) {
|
|
207
|
-
return _jsx(MailboxProvisioning, {});
|
|
207
|
+
return _jsx(MailboxProvisioning, { impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl });
|
|
208
208
|
}
|
|
209
209
|
let inner = null;
|
|
210
210
|
if (userUid && status === "checking") {
|
|
@@ -39,7 +39,7 @@ const AUTHORED_CANVAS = "rgb(255, 255, 255)";
|
|
|
39
39
|
export default function MessageBody({ messageUid, messageVersion, title, content, attachments, inlineParts, original = false, onAdaptable }) {
|
|
40
40
|
const [remote, setRemote] = useState(() => {
|
|
41
41
|
const cached = cachedBodyContent(messageUid, messageVersion);
|
|
42
|
-
return cached ? { status: "ready", content: cached } : { status: "loading" };
|
|
42
|
+
return cached ? { status: "ready", content: cached, uid: messageUid } : { status: "loading" };
|
|
43
43
|
});
|
|
44
44
|
const [attempt, setAttempt] = useState(0);
|
|
45
45
|
const supplied = content !== undefined;
|
|
@@ -49,19 +49,24 @@ export default function MessageBody({ messageUid, messageVersion, title, content
|
|
|
49
49
|
}
|
|
50
50
|
const cached = cachedBodyContent(messageUid, messageVersion);
|
|
51
51
|
if (cached) {
|
|
52
|
-
setRemote({ status: "ready", content: cached });
|
|
52
|
+
setRemote({ status: "ready", content: cached, uid: messageUid });
|
|
53
53
|
return;
|
|
54
54
|
}
|
|
55
55
|
const controller = new AbortController();
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
// The same message under a newer version (opening it marked it read, a flag or a label changed): what is shown stays - no skeleton, and
|
|
57
|
+
// the frame is not torn down and laid out again - until the fresh copy is here, and a failed read leaves it too.
|
|
58
|
+
const revalidating = remote.status === "ready" && remote.uid === messageUid;
|
|
59
|
+
if (!revalidating) {
|
|
60
|
+
setRemote({ status: "loading" });
|
|
61
|
+
}
|
|
62
|
+
fetchBodyContent(messageUid, messageVersion, controller.signal).then((loaded) => setRemote({ status: "ready", content: loaded, uid: messageUid }), (err) => {
|
|
63
|
+
if (!controller.signal.aborted && !revalidating) {
|
|
59
64
|
setRemote({ status: "error", message: err instanceof ApiRequestError ? err.message : "Could not load this message." });
|
|
60
65
|
}
|
|
61
66
|
});
|
|
62
67
|
return () => controller.abort();
|
|
63
68
|
}, [messageUid, messageVersion, supplied, attempt]);
|
|
64
|
-
const shown = content ? { status: "ready", content } : remote;
|
|
69
|
+
const shown = content ? { status: "ready", content, uid: messageUid } : remote;
|
|
65
70
|
if (shown.status === "loading") {
|
|
66
71
|
return _jsx(BodySkeleton, {});
|
|
67
72
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { ApiRequestError, apiUrl } from "../../../../../lib/util/api.js";
|
|
6
6
|
import { attachmentContentUrl } from "../../../../../lib/mail/mailApi.js";
|
|
7
7
|
import { findInlineAttachment, toDataUri } from "../../../../../lib/mail/inlineImages.js";
|
|
8
|
+
import { onSigningOut } from "../compose/composeFlushRegistry.js";
|
|
8
9
|
/** How many bodies are remembered for the session - a thread that is expanded, collapsed and expanded again does not ask the server twice. */
|
|
9
10
|
const CACHE_LIMIT = 40;
|
|
10
11
|
/** What the pane asks `/content` for. */
|
|
@@ -14,6 +15,7 @@ const cache = new Map();
|
|
|
14
15
|
export function clearBodyContentCache() {
|
|
15
16
|
cache.clear();
|
|
16
17
|
}
|
|
18
|
+
onSigningOut(clearBodyContentCache);
|
|
17
19
|
/** The remembered body for a message at a version, if any. */
|
|
18
20
|
export function cachedBodyContent(uid, version) {
|
|
19
21
|
return cache.get(`${uid}:${version}`);
|
|
@@ -33,7 +33,11 @@ export interface PrepareOptions {
|
|
|
33
33
|
* server removes one it could not point at a part) is asked for with no `contentId` and its `alt`. */
|
|
34
34
|
resolveCid?: (contentId: string | undefined, alt?: string) => string | undefined;
|
|
35
35
|
}
|
|
36
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* CSS with the constructs above neutralised. The patterns run on what a browser would read, not only on the raw text: `position:\66ixed` and
|
|
38
|
+
* `min-height:200\76h` are `position:fixed` and `200vh` to it, and a comment inside a name (`position/**\/:fixed`) is no obstacle either. When the
|
|
39
|
+
* decoded text holds one, that text (escapes resolved, comments dropped) is what is kept, neutralised; CSS whose escapes hide nothing is left as written.
|
|
40
|
+
*/
|
|
37
41
|
export declare function sanitizeCss(css: string): string;
|
|
38
42
|
/**
|
|
39
43
|
* Whether a mail's own markup declares that it supports a dark scheme: `<meta name="color-scheme">` naming dark,
|