@rapidmx/web-client 0.9.0 → 0.11.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/README.md +373 -88
- package/apps/admin/_layout.tsx +8 -2
- package/apps/admin/index.tsx +7 -1
- package/apps/admin/ingest-queue/index.tsx +2 -1
- package/apps/admin/mailboxes/[uid].tsx +23 -10
- package/apps/admin/quarantine/index.tsx +2 -1
- package/apps/admin/signing-certificates/index.tsx +343 -0
- package/apps/escrow/_layout.tsx +8 -2
- package/apps/shared/appearance/AppearanceForm.tsx +392 -0
- package/apps/shared/appearance/AppearanceHead.tsx +49 -0
- package/apps/shared/appearance/AppearanceProvider.tsx +470 -0
- package/apps/shared/appearance/ColorField.tsx +107 -0
- package/apps/shared/appearance/appearanceCache.ts +106 -0
- package/apps/shared/appearance/appearanceContext.ts +77 -0
- package/apps/shared/appearance/bootScript.ts +35 -0
- package/apps/shared/appearance/color.ts +169 -0
- package/apps/shared/appearance/photo.ts +88 -0
- package/apps/shared/appearance/resolvedTheme.ts +67 -0
- package/apps/shared/appearance/theme.ts +355 -0
- package/apps/shared/auth/accountUrl.ts +9 -0
- package/apps/shared/auth/adminAccess.ts +99 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +22 -10
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +107 -60
- package/apps/shared/components/admin/settings/BrandingForm.tsx +24 -0
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +2 -1
- package/apps/shared/components/admin/settings/PluginsManager.tsx +228 -25
- package/apps/shared/components/admin/setup/SetupWizard.tsx +2 -1
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +4 -5
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
- package/apps/shared/components/contacts/ContactForm.tsx +11 -5
- package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -3
- package/apps/shared/components/contacts/ContactsToolbar.tsx +98 -115
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +25 -8
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +13 -6
- package/apps/shared/components/layout/AppShell.tsx +477 -288
- package/apps/shared/components/layout/BrandingChrome.tsx +200 -13
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
- package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -164
- package/apps/shared/components/layout/RailIcon.tsx +100 -0
- package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -0
- package/apps/shared/components/layout/ThemeSwitch.tsx +84 -0
- package/apps/shared/components/layout/UserMenu.tsx +372 -185
- package/apps/shared/components/mail/ConversationList.tsx +271 -262
- package/apps/shared/components/mail/ConversationThreadPane.tsx +147 -94
- package/apps/shared/components/mail/LazyReadingPane.tsx +113 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +227 -222
- package/apps/shared/components/mail/MessageDetailPane.tsx +1537 -1387
- package/apps/shared/components/mail/OutboxBadge.tsx +44 -0
- package/apps/shared/components/mail/OutboxRowStatus.tsx +31 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -160
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1644 -1736
- package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
- package/apps/shared/components/mail/compose/composePerf.ts +46 -0
- package/apps/shared/components/mail/compose/encryptionRequirement.ts +86 -0
- package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
- package/apps/shared/components/mail/layout/MailShell.tsx +490 -472
- package/apps/shared/components/mail/reading/EncryptedBody.tsx +65 -0
- package/apps/shared/components/mail/reading/EncryptedPreview.tsx +35 -0
- package/apps/shared/components/mail/reading/MessageBody.tsx +240 -0
- package/apps/shared/components/mail/reading/MessageCard.tsx +174 -0
- package/apps/shared/components/mail/reading/bodyContent.ts +100 -0
- package/apps/shared/components/mail/reading/bodyHtml.ts +298 -0
- package/apps/shared/components/mail/reading/color.ts +245 -0
- package/apps/shared/components/mail/reading/frameControl.ts +142 -0
- package/apps/shared/components/mail/reading/frameDocument.ts +123 -0
- package/apps/shared/components/mail/reading/safeDocument.ts +24 -0
- package/apps/shared/components/mail/reading/themeAdaptation.ts +221 -0
- package/apps/shared/components/mail/reading/themeSurface.ts +90 -0
- package/apps/shared/components/mail/reading/viewOriginal.ts +38 -0
- package/apps/shared/components/mail/unreadStyle.tsx +75 -0
- package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +253 -240
- package/apps/shared/components/sharing/PrincipalPicker.tsx +145 -0
- package/apps/shared/components/tasks/TasksSidebar.tsx +5 -3
- package/apps/shared/components/tasks/layout/TasksShell.tsx +24 -8
- package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
- package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
- package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
- package/apps/shared/keyboard/dispatch.ts +124 -0
- package/apps/shared/keyboard/format.ts +89 -0
- package/apps/shared/keyboard/keymap.ts +114 -0
- package/apps/shared/keyboard/match.ts +44 -0
- package/apps/shared/keyboard/parse.ts +136 -0
- package/apps/shared/keyboard/platform.ts +34 -0
- package/apps/shared/keyboard/registry.ts +65 -0
- package/apps/shared/keyboard/targets.ts +79 -0
- package/apps/shared/keyboard/useShortcut.ts +50 -0
- package/apps/shared/keyboard/useShortcutProps.ts +17 -0
- package/apps/shared/mail/folderCounts.ts +308 -0
- package/apps/shared/mail/folderTree.ts +143 -0
- package/apps/shared/mail/listSnapshots.ts +87 -0
- package/apps/shared/mail/messageReadState.ts +85 -0
- package/apps/shared/mail/newMailNotifications.ts +183 -0
- package/apps/shared/mail/outbox/UnlockBridge.tsx +23 -0
- package/apps/shared/mail/outbox/composeBridge.ts +69 -0
- package/apps/shared/mail/outbox/outboxState.ts +88 -0
- package/apps/shared/mail/outbox/pendingSends.ts +146 -0
- package/apps/shared/mail/outbox/sendDecision.ts +157 -0
- package/apps/shared/mail/outbox/sendJob.ts +427 -0
- package/apps/shared/mail/outbox/sendOutcomes.ts +146 -0
- package/apps/shared/mail/outbox/sendState.ts +35 -0
- package/apps/shared/mail/outbox/useOutboxStatus.ts +61 -0
- package/apps/shared/mail/useMailConnection.ts +203 -0
- package/apps/shared/mail/useMailLiveUpdates.ts +277 -224
- package/apps/shared/mail/useMarkMessageRead.ts +47 -0
- package/apps/shared/mail/useNewMailNotifications.ts +178 -0
- package/apps/shared/mail/useUnreadTitle.ts +42 -0
- package/apps/shared/navigation/AppRouter.tsx +300 -0
- package/apps/shared/navigation/appHrefs.ts +23 -0
- package/apps/shared/navigation/frameContext.tsx +35 -0
- package/apps/shared/navigation/idle.ts +45 -0
- package/apps/shared/navigation/routerContext.tsx +83 -0
- package/apps/shared/navigation/routes.ts +72 -0
- package/apps/shared/notifications/NotificationCenter.tsx +215 -0
- package/apps/shared/notifications/NotificationHistoryDialog.tsx +90 -0
- package/apps/shared/notifications/apiErrors.ts +98 -0
- package/apps/shared/notifications/headerOffset.ts +35 -0
- package/apps/shared/notifications/pushStatus.ts +56 -0
- package/apps/shared/notifications/store.ts +549 -0
- package/apps/shared/notifications/systemErrors.ts +63 -0
- package/apps/shared/notifications/useNotifications.ts +41 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
- package/apps/shared/signing/enrollmentStorage.ts +33 -0
- package/apps/shared/signing/enrollmentTracker.ts +385 -0
- package/apps/shared/signing/enrollmentView.ts +251 -0
- package/apps/shared/signing/signingInfo.ts +58 -0
- package/apps/shared/signing/useNow.ts +19 -0
- package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -0
- package/apps/shared/styles/app.css +108 -10
- package/apps/www/_layout.tsx +8 -2
- package/apps/www/_routedPage.tsx +24 -0
- package/apps/www/_routes.ts +36 -0
- package/apps/www/calendar/index.tsx +64 -33
- package/apps/www/contacts/[uid].tsx +109 -107
- package/apps/www/contacts/index.tsx +62 -42
- package/apps/www/index.tsx +2291 -1917
- package/apps/www/messages/[uid].tsx +108 -101
- package/apps/www/settings/appearance/index.tsx +21 -0
- package/apps/www/settings/auto-reply/index.tsx +7 -8
- package/apps/www/settings/encryption/index.tsx +1287 -1249
- package/apps/www/settings/filters/[uid].tsx +7 -2
- package/apps/www/settings/filters/index.tsx +102 -99
- package/apps/www/settings/filters/new/index.tsx +140 -133
- package/apps/www/settings/labels/index.tsx +204 -201
- package/apps/www/settings/privacy/index.tsx +7 -9
- package/apps/www/settings/read-receipts/index.tsx +7 -8
- package/apps/www/settings/sharing/index.tsx +256 -274
- package/apps/www/settings/signatures/[uid].tsx +172 -167
- package/apps/www/settings/signatures/index.tsx +88 -85
- package/apps/www/settings/signatures/new/index.tsx +135 -129
- package/apps/www/tasks/index.tsx +29 -18
- package/dist/apps/admin/_layout.d.ts +5 -1
- package/dist/apps/admin/_layout.js +3 -2
- package/dist/apps/admin/index.js +3 -2
- package/dist/apps/admin/ingest-queue/index.js +2 -1
- package/dist/apps/admin/mailboxes/[uid].js +9 -6
- package/dist/apps/admin/quarantine/index.js +2 -1
- package/dist/apps/admin/signing-certificates/index.d.ts +3 -0
- package/dist/apps/admin/signing-certificates/index.js +161 -0
- package/dist/apps/escrow/_layout.d.ts +5 -1
- package/dist/apps/escrow/_layout.js +3 -2
- package/dist/apps/shared/appearance/AppearanceForm.d.ts +7 -0
- package/dist/apps/shared/appearance/AppearanceForm.js +131 -0
- package/dist/apps/shared/appearance/AppearanceHead.d.ts +15 -0
- package/dist/apps/shared/appearance/AppearanceHead.js +31 -0
- package/dist/apps/shared/appearance/AppearanceProvider.d.ts +32 -0
- package/dist/apps/shared/appearance/AppearanceProvider.js +390 -0
- package/dist/apps/shared/appearance/ColorField.d.ts +22 -0
- package/dist/apps/shared/appearance/ColorField.js +40 -0
- package/dist/apps/shared/appearance/appearanceCache.d.ts +45 -0
- package/dist/apps/shared/appearance/appearanceCache.js +80 -0
- package/dist/apps/shared/appearance/appearanceContext.d.ts +45 -0
- package/dist/apps/shared/appearance/appearanceContext.js +29 -0
- package/dist/apps/shared/appearance/bootScript.d.ts +16 -0
- package/dist/apps/shared/appearance/bootScript.js +33 -0
- package/dist/apps/shared/appearance/color.d.ts +65 -0
- package/dist/apps/shared/appearance/color.js +132 -0
- package/dist/apps/shared/appearance/photo.d.ts +17 -0
- package/dist/apps/shared/appearance/photo.js +79 -0
- package/dist/apps/shared/appearance/resolvedTheme.d.ts +21 -0
- package/dist/apps/shared/appearance/resolvedTheme.js +55 -0
- package/dist/apps/shared/appearance/theme.d.ts +111 -0
- package/dist/apps/shared/appearance/theme.js +251 -0
- package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
- package/dist/apps/shared/auth/accountUrl.js +8 -0
- package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
- package/dist/apps/shared/auth/adminAccess.js +89 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +16 -6
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +10 -5
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +49 -34
- package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +2 -0
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +7 -1
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +1 -1
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +106 -20
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +2 -1
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +8 -8
- package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
- package/dist/apps/shared/components/contacts/ContactForm.js +1 -1
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +4 -2
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +6 -2
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +28 -10
- package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +19 -6
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +7 -4
- package/dist/apps/shared/components/layout/AppShell.d.ts +34 -4
- package/dist/apps/shared/components/layout/AppShell.js +122 -17
- package/dist/apps/shared/components/layout/BrandingChrome.d.ts +69 -10
- package/dist/apps/shared/components/layout/BrandingChrome.js +140 -12
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +3 -1
- package/dist/apps/shared/components/layout/RailIcon.d.ts +21 -0
- package/dist/apps/shared/components/layout/RailIcon.js +85 -0
- package/dist/apps/shared/components/layout/ResponsiveToolbar.d.ts +55 -0
- package/dist/apps/shared/components/layout/ResponsiveToolbar.js +199 -0
- package/dist/apps/shared/components/layout/ThemeSwitch.d.ts +9 -0
- package/dist/apps/shared/components/layout/ThemeSwitch.js +54 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +25 -3
- package/dist/apps/shared/components/layout/UserMenu.js +88 -7
- package/dist/apps/shared/components/mail/ConversationList.js +5 -11
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +6 -2
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +87 -40
- package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
- package/dist/apps/shared/components/mail/LazyReadingPane.js +83 -0
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +6 -4
- package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -4
- package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +25 -8
- package/dist/apps/shared/components/mail/MessageDetailPane.js +167 -73
- package/dist/apps/shared/components/mail/OutboxBadge.d.ts +17 -0
- package/dist/apps/shared/components/mail/OutboxBadge.js +18 -0
- package/dist/apps/shared/components/mail/OutboxRowStatus.d.ts +10 -0
- package/dist/apps/shared/components/mail/OutboxRowStatus.js +19 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +38 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +97 -6
- package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
- package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +3 -2
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +316 -339
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
- package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
- package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
- package/dist/apps/shared/components/mail/compose/encryptionRequirement.d.ts +49 -0
- package/dist/apps/shared/components/mail/compose/encryptionRequirement.js +35 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +14 -4
- package/dist/apps/shared/components/mail/layout/MailShell.js +90 -104
- package/dist/apps/shared/components/mail/reading/EncryptedBody.d.ts +21 -0
- package/dist/apps/shared/components/mail/reading/EncryptedBody.js +22 -0
- package/dist/apps/shared/components/mail/reading/EncryptedPreview.d.ts +18 -0
- package/dist/apps/shared/components/mail/reading/EncryptedPreview.js +22 -0
- package/dist/apps/shared/components/mail/reading/MessageBody.d.ts +32 -0
- package/dist/apps/shared/components/mail/reading/MessageBody.js +136 -0
- package/dist/apps/shared/components/mail/reading/MessageCard.d.ts +62 -0
- package/dist/apps/shared/components/mail/reading/MessageCard.js +58 -0
- package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +38 -0
- package/dist/apps/shared/components/mail/reading/bodyContent.js +84 -0
- package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +47 -0
- package/dist/apps/shared/components/mail/reading/bodyHtml.js +267 -0
- package/dist/apps/shared/components/mail/reading/color.d.ts +63 -0
- package/dist/apps/shared/components/mail/reading/color.js +201 -0
- package/dist/apps/shared/components/mail/reading/frameControl.d.ts +33 -0
- package/dist/apps/shared/components/mail/reading/frameControl.js +120 -0
- package/dist/apps/shared/components/mail/reading/frameDocument.d.ts +52 -0
- package/dist/apps/shared/components/mail/reading/frameDocument.js +98 -0
- package/dist/apps/shared/components/mail/reading/safeDocument.d.ts +3 -0
- package/dist/apps/shared/components/mail/reading/safeDocument.js +12 -0
- package/dist/apps/shared/components/mail/reading/themeAdaptation.d.ts +56 -0
- package/dist/apps/shared/components/mail/reading/themeAdaptation.js +144 -0
- package/dist/apps/shared/components/mail/reading/themeSurface.d.ts +17 -0
- package/dist/apps/shared/components/mail/reading/themeSurface.js +83 -0
- package/dist/apps/shared/components/mail/reading/viewOriginal.d.ts +9 -0
- package/dist/apps/shared/components/mail/reading/viewOriginal.js +32 -0
- package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
- package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
- package/dist/apps/shared/components/settings/SigningCertificateCard.d.ts +32 -0
- package/dist/apps/shared/components/settings/SigningCertificateCard.js +98 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +16 -5
- package/dist/apps/shared/components/sharing/PrincipalPicker.d.ts +26 -0
- package/dist/apps/shared/components/sharing/PrincipalPicker.js +77 -0
- package/dist/apps/shared/components/tasks/TasksSidebar.js +4 -2
- package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +18 -6
- package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
- package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
- package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
- package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
- package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
- package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
- package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
- package/dist/apps/shared/keyboard/dispatch.js +109 -0
- package/dist/apps/shared/keyboard/format.d.ts +12 -0
- package/dist/apps/shared/keyboard/format.js +74 -0
- package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
- package/dist/apps/shared/keyboard/keymap.js +84 -0
- package/dist/apps/shared/keyboard/match.d.ts +20 -0
- package/dist/apps/shared/keyboard/match.js +31 -0
- package/dist/apps/shared/keyboard/parse.d.ts +31 -0
- package/dist/apps/shared/keyboard/parse.js +107 -0
- package/dist/apps/shared/keyboard/platform.d.ts +15 -0
- package/dist/apps/shared/keyboard/platform.js +22 -0
- package/dist/apps/shared/keyboard/registry.d.ts +39 -0
- package/dist/apps/shared/keyboard/registry.js +33 -0
- package/dist/apps/shared/keyboard/targets.d.ts +14 -0
- package/dist/apps/shared/keyboard/targets.js +67 -0
- package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
- package/dist/apps/shared/keyboard/useShortcut.js +35 -0
- package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
- package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
- package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
- package/dist/apps/shared/mail/folderCounts.js +218 -0
- package/dist/apps/shared/mail/folderTree.d.ts +55 -0
- package/dist/apps/shared/mail/folderTree.js +116 -0
- package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
- package/dist/apps/shared/mail/listSnapshots.js +43 -0
- package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
- package/dist/apps/shared/mail/messageReadState.js +63 -0
- package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
- package/dist/apps/shared/mail/newMailNotifications.js +138 -0
- package/dist/apps/shared/mail/outbox/UnlockBridge.d.ts +5 -0
- package/dist/apps/shared/mail/outbox/UnlockBridge.js +18 -0
- package/dist/apps/shared/mail/outbox/composeBridge.d.ts +34 -0
- package/dist/apps/shared/mail/outbox/composeBridge.js +32 -0
- package/dist/apps/shared/mail/outbox/outboxState.d.ts +41 -0
- package/dist/apps/shared/mail/outbox/outboxState.js +42 -0
- package/dist/apps/shared/mail/outbox/pendingSends.d.ts +39 -0
- package/dist/apps/shared/mail/outbox/pendingSends.js +109 -0
- package/dist/apps/shared/mail/outbox/sendDecision.d.ts +80 -0
- package/dist/apps/shared/mail/outbox/sendDecision.js +94 -0
- package/dist/apps/shared/mail/outbox/sendJob.d.ts +68 -0
- package/dist/apps/shared/mail/outbox/sendJob.js +319 -0
- package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +3 -0
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +141 -0
- package/dist/apps/shared/mail/outbox/sendState.d.ts +25 -0
- package/dist/apps/shared/mail/outbox/sendState.js +8 -0
- package/dist/apps/shared/mail/outbox/useOutboxStatus.d.ts +11 -0
- package/dist/apps/shared/mail/outbox/useOutboxStatus.js +49 -0
- package/dist/apps/shared/mail/useMailConnection.d.ts +56 -0
- package/dist/apps/shared/mail/useMailConnection.js +138 -0
- package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +29 -9
- package/dist/apps/shared/mail/useMailLiveUpdates.js +60 -32
- package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
- package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
- package/dist/apps/shared/mail/useNewMailNotifications.d.ts +35 -0
- package/dist/apps/shared/mail/useNewMailNotifications.js +130 -0
- package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
- package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
- package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
- package/dist/apps/shared/navigation/AppRouter.js +242 -0
- package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
- package/dist/apps/shared/navigation/appHrefs.js +20 -0
- package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
- package/dist/apps/shared/navigation/frameContext.js +24 -0
- package/dist/apps/shared/navigation/idle.d.ts +14 -0
- package/dist/apps/shared/navigation/idle.js +43 -0
- package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
- package/dist/apps/shared/navigation/routerContext.js +56 -0
- package/dist/apps/shared/navigation/routes.d.ts +32 -0
- package/dist/apps/shared/navigation/routes.js +37 -0
- package/dist/apps/shared/notifications/NotificationCenter.d.ts +19 -0
- package/dist/apps/shared/notifications/NotificationCenter.js +90 -0
- package/dist/apps/shared/notifications/NotificationHistoryDialog.d.ts +10 -0
- package/dist/apps/shared/notifications/NotificationHistoryDialog.js +36 -0
- package/dist/apps/shared/notifications/apiErrors.d.ts +19 -0
- package/dist/apps/shared/notifications/apiErrors.js +86 -0
- package/dist/apps/shared/notifications/headerOffset.d.ts +8 -0
- package/dist/apps/shared/notifications/headerOffset.js +33 -0
- package/dist/apps/shared/notifications/pushStatus.d.ts +9 -0
- package/dist/apps/shared/notifications/pushStatus.js +54 -0
- package/dist/apps/shared/notifications/store.d.ts +136 -0
- package/dist/apps/shared/notifications/store.js +416 -0
- package/dist/apps/shared/notifications/systemErrors.d.ts +9 -0
- package/dist/apps/shared/notifications/systemErrors.js +58 -0
- package/dist/apps/shared/notifications/useNotifications.d.ts +17 -0
- package/dist/apps/shared/notifications/useNotifications.js +18 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
- package/dist/apps/shared/signing/enrollmentStorage.d.ts +10 -0
- package/dist/apps/shared/signing/enrollmentStorage.js +33 -0
- package/dist/apps/shared/signing/enrollmentTracker.d.ts +73 -0
- package/dist/apps/shared/signing/enrollmentTracker.js +307 -0
- package/dist/apps/shared/signing/enrollmentView.d.ts +57 -0
- package/dist/apps/shared/signing/enrollmentView.js +214 -0
- package/dist/apps/shared/signing/signingInfo.d.ts +11 -0
- package/dist/apps/shared/signing/signingInfo.js +48 -0
- package/dist/apps/shared/signing/useNow.d.ts +2 -0
- package/dist/apps/shared/signing/useNow.js +18 -0
- package/dist/apps/shared/signing/useSigningEnrollmentWatcher.d.ts +16 -0
- package/dist/apps/shared/signing/useSigningEnrollmentWatcher.js +87 -0
- package/dist/apps/shared/styles/app.css +108 -10
- package/dist/apps/www/_layout.d.ts +5 -1
- package/dist/apps/www/_layout.js +3 -2
- package/dist/apps/www/_routedPage.d.ts +12 -0
- package/dist/apps/www/_routedPage.js +19 -0
- package/dist/apps/www/_routes.d.ts +11 -0
- package/dist/apps/www/_routes.js +30 -0
- package/dist/apps/www/calendar/index.d.ts +2 -2
- package/dist/apps/www/calendar/index.js +41 -11
- package/dist/apps/www/contacts/[uid].d.ts +3 -9
- package/dist/apps/www/contacts/[uid].js +11 -9
- package/dist/apps/www/contacts/index.d.ts +2 -2
- package/dist/apps/www/contacts/index.js +30 -24
- package/dist/apps/www/index.d.ts +2 -2
- package/dist/apps/www/index.js +332 -48
- package/dist/apps/www/messages/[uid].d.ts +3 -7
- package/dist/apps/www/messages/[uid].js +10 -5
- package/dist/apps/www/settings/appearance/index.d.ts +4 -0
- package/dist/apps/www/settings/appearance/index.js +13 -0
- package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
- package/dist/apps/www/settings/auto-reply/index.js +6 -7
- package/dist/apps/www/settings/encryption/index.d.ts +2 -2
- package/dist/apps/www/settings/encryption/index.js +106 -96
- package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
- package/dist/apps/www/settings/filters/[uid].js +6 -2
- package/dist/apps/www/settings/filters/index.d.ts +2 -2
- package/dist/apps/www/settings/filters/index.js +3 -1
- package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
- package/dist/apps/www/settings/filters/new/index.js +9 -3
- package/dist/apps/www/settings/labels/index.d.ts +2 -2
- package/dist/apps/www/settings/labels/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.d.ts +2 -2
- package/dist/apps/www/settings/privacy/index.js +8 -9
- package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
- package/dist/apps/www/settings/read-receipts/index.js +6 -7
- package/dist/apps/www/settings/sharing/index.d.ts +2 -2
- package/dist/apps/www/settings/sharing/index.js +22 -34
- package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
- package/dist/apps/www/settings/signatures/[uid].js +6 -2
- package/dist/apps/www/settings/signatures/index.d.ts +2 -2
- package/dist/apps/www/settings/signatures/index.js +3 -1
- package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
- package/dist/apps/www/settings/signatures/new/index.js +9 -4
- package/dist/apps/www/tasks/index.d.ts +2 -2
- package/dist/apps/www/tasks/index.js +22 -16
- package/package.json +2 -2
- package/apps/shared/components/mail/compose/SendFailureAlert.tsx +0 -48
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +0 -14
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +0 -11
|
@@ -6,65 +6,49 @@ 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, HiOutlineChevronDown, HiOutlineLockClosed, HiOutlineMinus, HiOutlinePaperClip, HiOutlineTrash, HiOutlineXMark, } from "react-icons/hi2";
|
|
8
8
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
9
|
-
import { assembleDraft,
|
|
9
|
+
import { assembleDraft, attachmentContentUrl, createDraft, deleteMessage, getMailbox, getMessage, listFolders, listMailboxes, uploadAttachment, } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
10
10
|
import { listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
|
|
11
|
-
import { describeSendFailure } from "@rapidmx/react-shared/mail/sendFailure.js";
|
|
12
11
|
import { buildComposeBodyHtml } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
|
|
13
12
|
import { peekMailboxWritability, useMailboxWritability } from "../writableMailboxes.js";
|
|
14
|
-
import {
|
|
13
|
+
import { resolveRecipientEncryption } from "@rapidmx/react-shared/crypto/composeSecurity.js";
|
|
15
14
|
import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
16
15
|
import { findActivePublicKey, getEncryptionPolicy, lookupKeys } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
17
16
|
import { useUnlockPrompt } from "../../layout/UnlockPromptProvider.js";
|
|
18
|
-
import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
|
|
19
|
-
import { applyBaselineOuterHeaders, assembleOutboundMime, buildEncryptedMessage, buildSignedOnlyMessage } from "@rapidmx/react-shared/crypto/smimeMessage.js";
|
|
20
17
|
import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
|
|
18
|
+
import { notify } from "../../../notifications/store.js";
|
|
19
|
+
import { evaluateEncryptionRequirement } from "./encryptionRequirement.js";
|
|
20
|
+
import { decideSend } from "../../../mail/outbox/sendDecision.js";
|
|
21
|
+
import { sendDecisionInput, startSend } from "../../../mail/outbox/sendJob.js";
|
|
22
|
+
import { useCompose } from "./ComposeContext.js";
|
|
23
|
+
import { ariaKeyShortcuts, withHint } from "../../../keyboard/format.js";
|
|
24
|
+
import { SHORTCUTS } from "../../../keyboard/keymap.js";
|
|
25
|
+
import { useKeyEnvironment } from "../../../keyboard/ShortcutProvider.js";
|
|
26
|
+
import { useShortcut } from "../../../keyboard/useShortcut.js";
|
|
21
27
|
import { isSigningOut, registerComposeFlush } from "./composeFlushRegistry.js";
|
|
28
|
+
import { markComposePhase } from "./composePerf.js";
|
|
22
29
|
import RecipientInput from "./RecipientInput.js";
|
|
23
30
|
import { parseRecipientList } from "./recipients.js";
|
|
24
31
|
import RichTextEditor from "./RichTextEditor.js";
|
|
25
32
|
import ScheduleSendPicker from "./ScheduleSendPicker.js";
|
|
26
|
-
import SendFailureAlert from "./SendFailureAlert.js";
|
|
27
33
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
28
34
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
29
35
|
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
30
36
|
/** Debounce between the last edit and the draft autosave. */
|
|
31
37
|
export const DEFAULT_AUTOSAVE_DELAY_MS = 2000;
|
|
32
|
-
/** Automatic retries of a failed mailbox/encryption-policy
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
* (an uploaded attachment previewed via its content URL, or a `cid:` reference) would never be carried -
|
|
36
|
-
* only `assembleDraft()`'s server-side plaintext path rewrites those into real MIME parts. */
|
|
37
|
-
const INLINE_IMAGE_PATTERN = /<img\b[^>]*\bsrc\s*=\s*["']?(?:cid:|[^"'\s>]*\/mail\/attachments\/[^"'\s>]+\/content)/i;
|
|
38
|
-
const KEYS_LOCKED_SIGN_MESSAGE = "This message can't be signed right now - your signing key is locked or your mailbox details couldn't be loaded. Unlock and send again, or send it without signing.";
|
|
39
|
-
const KEYS_LOCKED_ENCRYPT_MESSAGE = "This message can't be encrypted right now - your encryption key is locked. Unlock and send again, or send it without encryption.";
|
|
40
|
-
const POLICY_UNAVAILABLE_MESSAGE = "Your encryption settings couldn't be loaded, so this message can't be encrypted right now. Try again, or send it without encryption.";
|
|
41
|
-
const BCC_ENCRYPTED_MESSAGE = "Bcc recipients can't be used with encrypted messages. Remove Bcc recipients or turn off encryption.";
|
|
42
|
-
const LOOKUP_UNAVAILABLE_MESSAGE = "Your recipients' encryption keys couldn't be checked, so this message might go out unencrypted when it should be encrypted. Try again, or send it without encryption.";
|
|
38
|
+
/** Automatic, silent retries of a failed mailbox/encryption-policy (or recipient key) load, one entry per retry - a flapping gateway is waited out
|
|
39
|
+
* in the background, without a word to the user. */
|
|
40
|
+
export const DEFAULT_CRYPTO_RETRY_DELAYS_MS = [1000, 2000, 4000, 8000, 15000, 30000, 60000];
|
|
43
41
|
/** Thrown by `deleteDraft()` when the server copy is no longer this window's draft (sent or scheduled elsewhere). */
|
|
44
42
|
const NO_LONGER_A_DRAFT_MESSAGE = "This message was already sent or scheduled from another window, so it wasn't deleted.";
|
|
45
|
-
/** The server refuses signed/encrypted mail whose From display name differs from its own safe form, which drops a
|
|
46
|
-
* name containing `@` (or a look-alike) or a line break. */
|
|
47
|
-
const UNSAFE_DISPLAY_NAME_PATTERN = /[@@﹫\r\n]/;
|
|
48
43
|
/** Whether `fresh` (a re-read of a draft) is still an unsent draft in `folderUid`: never delete a message another
|
|
49
44
|
* window has since sent, scheduled, or started sending. */
|
|
50
45
|
function isStillDraft(fresh, folderUid) {
|
|
51
46
|
return fresh.folderUid === folderUid && !fresh.scheduledSendTime && !fresh.scheduledSendLeaseExpiresAt && !fresh.scheduledSendRelayedAt;
|
|
52
47
|
}
|
|
53
|
-
/** Any tier of `policy` auto-encrypts - only then can a message with recipients still to be added turn out encrypted
|
|
54
|
-
* without the user asking. */
|
|
55
|
-
function policyCanAutoEncrypt(policy) {
|
|
56
|
-
return [policy.encryptSameOrg, policy.encryptFederated, policy.encryptExternal].includes("automatic");
|
|
57
|
-
}
|
|
58
48
|
const SAVE_STATUS_LABEL = { idle: "", saving: "Saving…", saved: "Draft saved", error: "Couldn't save draft" };
|
|
59
49
|
const DISCARD_TITLE = "Discard this draft?";
|
|
50
|
+
/** Shown only for a message that is actually headed for encryption (asked for, or applied by the loaded policy) - see `encryptionRequirement.ts`. */
|
|
60
51
|
const ENCRYPTED_CLOSE_MESSAGE = "Encrypted messages aren't saved as drafts, so closing this window discards what you've written.";
|
|
61
|
-
const CHECKING_CLOSE_MESSAGE = "This message may be encrypted, and that's still being checked, so it can't be saved as a draft yet. Keep editing and close again in a moment, or discard it.";
|
|
62
|
-
const CRYPTO_UNAVAILABLE_MESSAGE = "Your encryption settings couldn't be checked, so this draft isn't being saved - it might be a message that must be encrypted.";
|
|
63
|
-
const NO_RECIPIENTS_CLOSE_MESSAGE = "This message may be encrypted once its recipients are known, so it isn't saved as a draft until you add them. Add a recipient and close again, or discard it.";
|
|
64
|
-
const SEND_FAILED_TITLE = "This message wasn't sent";
|
|
65
|
-
function sendFailedCloseMessage(reason) {
|
|
66
|
-
return `Sending it failed (${reason.replace(/\.$/, "")}). Closing keeps it as a draft, unsent. Keep editing to try again, or discard it.`;
|
|
67
|
-
}
|
|
68
52
|
const FIELD_ROW = "flex items-center gap-2 px-3 py-1.5 border-b border-border";
|
|
69
53
|
const FIELD_INPUT = "flex-1 min-w-0 text-sm bg-transparent outline-none";
|
|
70
54
|
const MIN_WIDTH = 320;
|
|
@@ -80,8 +64,9 @@ const VIEWPORT_MARGIN = 24;
|
|
|
80
64
|
function parseAddresses(value) {
|
|
81
65
|
return parseRecipientList(value);
|
|
82
66
|
}
|
|
83
|
-
function HeaderButton({ label, onClick, icon: Icon, disabled, }) {
|
|
84
|
-
|
|
67
|
+
function HeaderButton({ label, onClick, icon: Icon, disabled, shortcut, }) {
|
|
68
|
+
const env = useKeyEnvironment();
|
|
69
|
+
return (_jsx("button", { type: "button", "aria-label": label, title: shortcut ? withHint(label, shortcut, env) : label, "aria-keyshortcuts": shortcut ? ariaKeyShortcuts(shortcut, env) : undefined, onClick: onClick, disabled: disabled, className: "w-6 h-6 flex items-center justify-center rounded-sm text-white/80 hover:bg-white/15 hover:text-white disabled:opacity-40 disabled:cursor-not-allowed", children: _jsx(Icon, { size: 14 }) }));
|
|
85
70
|
}
|
|
86
71
|
/**
|
|
87
72
|
* A floating, Gmail-style compose window — overlays whatever app is currently showing instead of
|
|
@@ -98,7 +83,15 @@ function HeaderButton({ label, onClick, icon: Icon, disabled, }) {
|
|
|
98
83
|
* once.
|
|
99
84
|
*/
|
|
100
85
|
export default function ComposeWindow({ session, onClose, onToggleMinimize, userUid, trusted, autosaveDelayMs = DEFAULT_AUTOSAVE_DELAY_MS, cryptoRetryDelaysMs = DEFAULT_CRYPTO_RETRY_DELAYS_MS, }) {
|
|
101
|
-
const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, minimized } = session;
|
|
86
|
+
const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, minimized, quotePending, late, resume } = session;
|
|
87
|
+
// What a reply worked out after this window opened (see `OpenComposeInput.pending`): better recipients, applied below
|
|
88
|
+
// only to fields still exactly as they opened - so these, not `initialTo`/`initialCc`, are what "untouched" is measured against.
|
|
89
|
+
const baselineTo = late?.to ?? initialTo;
|
|
90
|
+
const baselineCc = late?.cc ?? initialCc;
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
markComposePhase(id, "shell");
|
|
93
|
+
markComposePhase(id, "chunk");
|
|
94
|
+
}, []);
|
|
102
95
|
// The sending ("From") mailbox. A reply/forward session names the original message's mailbox; a fresh
|
|
103
96
|
// compose leaves it unset and defaults to the caller's own mailbox once `listMailboxes()` resolves.
|
|
104
97
|
// Everything mailbox-scoped below (Drafts folder, draft, signatures, crypto context) keys off this.
|
|
@@ -107,35 +100,40 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
107
100
|
const [mailboxes, setMailboxes] = useState([]);
|
|
108
101
|
// Attachments and inline images upload onto the current draft's uid, which lives in one mailbox's
|
|
109
102
|
// Drafts folder - so once anything has been uploaded the sender can no longer be switched.
|
|
110
|
-
const [hasUploads, setHasUploads] = useState(
|
|
103
|
+
const [hasUploads, setHasUploads] = useState(!!resume && resume.attachments.length > 0);
|
|
111
104
|
const isMobile = useIsMobile();
|
|
112
105
|
const { requestUnlock } = useUnlockPrompt();
|
|
106
|
+
const { openCompose } = useCompose();
|
|
107
|
+
const keyEnv = useKeyEnvironment();
|
|
113
108
|
// Bumped after a successful on-demand unlock to force a re-render - `getUnlockedKeys()` below is a
|
|
114
109
|
// plain read from a module-level store, not React state, so nothing else would pick up the change.
|
|
115
110
|
const [, setUnlockRefresh] = useState(0);
|
|
116
111
|
const windowRef = useRef(null);
|
|
117
112
|
const [draftsFolderUid, setDraftsFolderUid] = useState();
|
|
118
113
|
const [folderError, setFolderError] = useState(null);
|
|
119
|
-
|
|
114
|
+
// A window re-opened around a message that was already composed (a failed send's "Open draft") continues that server draft and starts with
|
|
115
|
+
// every field as it was typed; its body is not seeded from a signature or a quote.
|
|
116
|
+
const [draft, setDraft] = useState(resume?.draft ?? null);
|
|
120
117
|
// Drafts replaced by a From switch, awaiting deletion until their replacement has actually been created.
|
|
121
118
|
const supersededDraftsRef = useRef([]);
|
|
122
119
|
const [draftError, setDraftError] = useState(null);
|
|
123
120
|
const [expanded, setExpanded] = useState(false);
|
|
124
121
|
const [manualSize, setManualSize] = useState(null);
|
|
125
|
-
const [to, setTo] = useState(initialTo ?? "");
|
|
126
|
-
const [cc, setCc] = useState(initialCc ?? "");
|
|
127
|
-
const [bcc, setBcc] = useState("");
|
|
128
|
-
const [showCcBcc, setShowCcBcc] = useState(!!initialCc);
|
|
129
|
-
const [subject, setSubject] = useState(initialSubject ?? "");
|
|
130
|
-
const [html, setHtml] = useState("");
|
|
131
|
-
const [contentReady, setContentReady] = useState(
|
|
132
|
-
const [attachments, setAttachments] = useState([]);
|
|
122
|
+
const [to, setTo] = useState(resume?.to ?? initialTo ?? "");
|
|
123
|
+
const [cc, setCc] = useState(resume?.cc ?? initialCc ?? "");
|
|
124
|
+
const [bcc, setBcc] = useState(resume?.bcc ?? "");
|
|
125
|
+
const [showCcBcc, setShowCcBcc] = useState(!!(resume ? resume.cc || resume.bcc : initialCc));
|
|
126
|
+
const [subject, setSubject] = useState(resume?.subject ?? initialSubject ?? "");
|
|
127
|
+
const [html, setHtml] = useState(resume?.html ?? "");
|
|
128
|
+
const [contentReady, setContentReady] = useState(!!resume);
|
|
129
|
+
const [attachments, setAttachments] = useState(resume?.attachments ?? []);
|
|
133
130
|
const [attachError, setAttachError] = useState(null);
|
|
131
|
+
// A problem with the message as composed (no recipient, an attachment that can't be signed): shown here, and the window stays open. What
|
|
132
|
+
// goes wrong after Send was accepted is a pop-up instead (see `sendJob.ts`).
|
|
134
133
|
const [sendError, setSendError] = useState(null);
|
|
135
|
-
//
|
|
136
|
-
const [
|
|
137
|
-
const [
|
|
138
|
-
const [requestReceipt, setRequestReceipt] = useState(false);
|
|
134
|
+
// Send was pressed while an attachment was still uploading: the window waits (and says so) rather than send without it.
|
|
135
|
+
const [waitingForUploads, setWaitingForUploads] = useState(false);
|
|
136
|
+
const [requestReceipt, setRequestReceipt] = useState(!!resume?.requestReceipt);
|
|
139
137
|
const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
|
|
140
138
|
const scheduleButtonRef = useRef(null);
|
|
141
139
|
const [mailbox, setMailbox] = useState(null);
|
|
@@ -144,11 +142,10 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
144
142
|
// Digital Signatures - a list that appends a footer after signing invalidates the signature) - see
|
|
145
143
|
// `MessageDetailPane.tsx`'s handleReply()/handleReplyAll() for where this is computed. The user can
|
|
146
144
|
// still turn it back on; this only changes the default.
|
|
147
|
-
const [signEnabled, setSignEnabled] = useState(!suppressSigning);
|
|
145
|
+
const [signEnabled, setSignEnabled] = useState(resume ? resume.signEnabled : !suppressSigning);
|
|
148
146
|
// A reply to or forward of an encrypted message starts (and, after a From switch, restarts) with encryption
|
|
149
147
|
// requested - see `OpenComposeInput.encrypt`.
|
|
150
|
-
const [encryptRequested, setEncryptRequested] = useState(!!initialEncrypt);
|
|
151
|
-
const [encryptionBlocked, setEncryptionBlocked] = useState(null);
|
|
148
|
+
const [encryptRequested, setEncryptRequested] = useState(resume ? resume.encryptRequested : !!initialEncrypt);
|
|
152
149
|
// Compose-time discovery (spec: "Discovery occurs ... when the user addresses a new message to a
|
|
153
150
|
// recipient", never on receipt) - keyed by address so a recipient already looked up isn't re-fetched
|
|
154
151
|
// just because the user re-focuses the field. Only ever grows via `checkRecipientDiscovery()` below;
|
|
@@ -158,16 +155,14 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
158
155
|
// look one up twice), and a counter bumped by a From switch so a lookup for the previous sender is dropped.
|
|
159
156
|
const lookupsInFlightRef = useRef(new Set());
|
|
160
157
|
const discoveryGenerationRef = useRef(0);
|
|
161
|
-
// A failed lookup stores no status
|
|
162
|
-
//
|
|
163
|
-
// run out the address is "exhausted" - shown as a check that couldn't be done, with Retry.
|
|
158
|
+
// A failed lookup stores no status: the recipient is simply "not known to be encryptable" (fail open - see `encryptionRequirement.ts`), and
|
|
159
|
+
// the lookup is retried in the background with the same backoff as the mailbox/policy load, without a word to the user.
|
|
164
160
|
const lookupFailuresRef = useRef(new Map());
|
|
165
161
|
const lookupRetryTimersRef = useRef(new Set());
|
|
166
162
|
const [lookupRetryToken, setLookupRetryToken] = useState(0);
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
// silent downgrade to plaintext; the user has to explicitly pick the override.
|
|
163
|
+
// A send refused *before it left* because signing/encryption was asked for (or the loaded policy applies it) but can't happen right now
|
|
164
|
+
// (keys locked, Bcc recipients on an encrypted message, a recipient without a key) - never a silent downgrade to plaintext; the user
|
|
165
|
+
// has to explicitly pick the override.
|
|
171
166
|
const [securityBlock, setSecurityBlock] = useState(null);
|
|
172
167
|
// The schedule time of the send that got blocked (undefined for an immediate send), so the banners'
|
|
173
168
|
// override buttons replay the same kind of send the user originally asked for.
|
|
@@ -188,8 +183,11 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
188
183
|
const lastSavedRef = useRef(null);
|
|
189
184
|
/** An edit is waiting on the autosave debounce - flushed if the window unmounts first. */
|
|
190
185
|
const pendingSaveRef = useRef(false);
|
|
191
|
-
/**
|
|
192
|
-
const
|
|
186
|
+
/** Send was pressed and is being validated/handed over: a second press (a double click, the shortcut held or repeated) does nothing. */
|
|
187
|
+
const submittingRef = useRef(false);
|
|
188
|
+
/** Attachment/inline-image uploads on the wire, and who is waiting for them to finish (Send pressed meanwhile). */
|
|
189
|
+
const uploadsInFlightRef = useRef(0);
|
|
190
|
+
const uploadWaitersRef = useRef([]);
|
|
193
191
|
/** The latest save request, if any - each save is chained after the one before it. Always settles (never
|
|
194
192
|
* rejects), to the saved message, or `undefined` when that save failed. */
|
|
195
193
|
const saveInFlightRef = useRef(Promise.resolve(undefined));
|
|
@@ -197,13 +195,10 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
197
195
|
const finishedRef = useRef(false);
|
|
198
196
|
// A mailbox with no keys enrolled yet just means sign/encrypt stay unavailable for this compose session -
|
|
199
197
|
// encryption is optional and gradual by design (see `KeyEnrollmentGate`'s own doc comment).
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
const [cryptoContextReady, setCryptoContextReady] = useState(false);
|
|
205
|
-
const [cryptoLoadFailed, setCryptoLoadFailed] = useState(false);
|
|
206
|
-
const [cryptoRetryToken, setCryptoRetryToken] = useState(0);
|
|
198
|
+
// The mailbox and the encryption policy load in the background and *nothing waits for them*: until both have loaded, the message is treated as
|
|
199
|
+
// not encrypted - its draft saves, Close and Send work, and no message about it is shown (see `encryptionRequirement.ts`). A failed load is
|
|
200
|
+
// retried silently with backoff (`cryptoRetryDelaysMs`); if it later turns out the message must be encrypted, `encryptionDecided` (below) starts
|
|
201
|
+
// to apply - see the transition rules there.
|
|
207
202
|
useEffect(() => {
|
|
208
203
|
let cancelled = false;
|
|
209
204
|
listMailboxes({ limit: 100 })
|
|
@@ -238,8 +233,6 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
238
233
|
let mailboxLoaded = mailbox?.uid === mailboxUid;
|
|
239
234
|
let policyLoaded = !!encryptionPolicy;
|
|
240
235
|
let retries = 0;
|
|
241
|
-
setCryptoContextReady(false);
|
|
242
|
-
setCryptoLoadFailed(false);
|
|
243
236
|
async function load() {
|
|
244
237
|
await Promise.all([
|
|
245
238
|
mailboxLoaded ||
|
|
@@ -257,13 +250,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
257
250
|
}
|
|
258
251
|
}, () => undefined),
|
|
259
252
|
]);
|
|
260
|
-
if (cancelled) {
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
const failed = !mailboxLoaded || !policyLoaded;
|
|
264
|
-
setCryptoContextReady(true);
|
|
265
|
-
setCryptoLoadFailed(failed);
|
|
266
|
-
if (failed && retries < cryptoRetryDelaysMs.length) {
|
|
253
|
+
if (!cancelled && (!mailboxLoaded || !policyLoaded) && retries < cryptoRetryDelaysMs.length) {
|
|
267
254
|
timer = setTimeout(() => void load(), cryptoRetryDelaysMs[retries++]);
|
|
268
255
|
}
|
|
269
256
|
}
|
|
@@ -272,7 +259,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
272
259
|
cancelled = true;
|
|
273
260
|
clearTimeout(timer);
|
|
274
261
|
};
|
|
275
|
-
}, [mailboxUid
|
|
262
|
+
}, [mailboxUid]);
|
|
276
263
|
useEffect(() => {
|
|
277
264
|
if (!mailboxUid) {
|
|
278
265
|
return;
|
|
@@ -299,14 +286,14 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
299
286
|
* Runs discovery for whichever of `addresses` haven't already been looked up this compose session,
|
|
300
287
|
* caching the result so the small per-recipient indicator below the recipient fields can update
|
|
301
288
|
* without waiting for Send. Called from the To/Cc/Bcc fields' own `onBlur` - not on every keystroke,
|
|
302
|
-
* and not the
|
|
289
|
+
* and not the lookup the background send (`sendJob.ts`) performs again at send time (deliberately: this is a
|
|
303
290
|
* best-effort UI hint, not something a stale value here should be trusted to skip at send time).
|
|
304
291
|
* A mailbox/policy fetch that hasn't resolved yet just leaves that address with no indicator - the effect
|
|
305
292
|
* below runs it again for every current recipient once both have loaded. A failed lookup leaves it without
|
|
306
|
-
* a status too, and is retried after `cryptoRetryDelaysMs` (then only on blur
|
|
293
|
+
* a status too, and is retried silently after `cryptoRetryDelaysMs` (then only on blur).
|
|
307
294
|
*
|
|
308
|
-
* The statuses also drive
|
|
309
|
-
*
|
|
295
|
+
* The statuses also drive `encryptionRequirement.ts`: only when every recipient has one, and the loaded policy
|
|
296
|
+
* auto-encrypts for all of them, does the message turn into one that is never saved as a plaintext draft.
|
|
310
297
|
*/
|
|
311
298
|
function checkRecipientDiscovery(addresses) {
|
|
312
299
|
if (!mailbox || !encryptionPolicy) {
|
|
@@ -335,12 +322,11 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
335
322
|
});
|
|
336
323
|
}
|
|
337
324
|
}
|
|
338
|
-
/** Schedules the next automatic retry of a failed lookup
|
|
325
|
+
/** Schedules the next automatic, silent retry of a failed lookup (until the backoff has run out; a later blur tries again). */
|
|
339
326
|
function noteLookupFailed(address) {
|
|
340
327
|
const failures = (lookupFailuresRef.current.get(address) ?? 0) + 1;
|
|
341
328
|
lookupFailuresRef.current.set(address, failures);
|
|
342
329
|
if (failures > cryptoRetryDelaysMs.length) {
|
|
343
|
-
setExhaustedLookups((prev) => ({ ...prev, [address]: true }));
|
|
344
330
|
return;
|
|
345
331
|
}
|
|
346
332
|
const timer = setTimeout(() => {
|
|
@@ -359,7 +345,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
359
345
|
useEffect(() => {
|
|
360
346
|
checkRecipientDiscovery([...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]);
|
|
361
347
|
}, [mailbox, encryptionPolicy, lookupRetryToken]);
|
|
362
|
-
// Resolves the mailbox's default signature (if any) for `signatureContext` and seeds `html` with it
|
|
348
|
+
// Resolves the mailbox's default signature (if any) for `signatureContext` and seeds `html` with it (not for a window re-opened around a composed message)
|
|
363
349
|
// plus any quoted original message (laid out by `buildComposeBodyHtml()`: an empty first paragraph for the
|
|
364
350
|
// caret, then the signature, then the quote), before `RichTextEditor` ever mounts (gated by `contentReady`
|
|
365
351
|
// below) — `RichTextEditor`'s own doc comment is explicit that `value` only seeds its *initial*
|
|
@@ -368,26 +354,41 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
368
354
|
// non-blocking fetch in this codebase (e.g. `ConversationList`'s own child-message fetch) — no
|
|
369
355
|
// signature is a completely legitimate outcome, so this falls back to just the quoted content
|
|
370
356
|
// (if any) rather than surfacing an error over what's a cosmetic nicety.
|
|
357
|
+
//
|
|
358
|
+
// A reply or forward opens before its quoted original has been fetched (`quotePending`, see
|
|
359
|
+
// `OpenComposeInput.pending`). The body is seeded as soon as the signature is here, without the quote, so the editor is usable
|
|
360
|
+
// at once however slow the original is to come; the quote is added at the end of the editor's document when it arrives
|
|
361
|
+
// (`appendHtml` below) - the same place, under the signature, that a body seeded with it would have had it.
|
|
362
|
+
const [defaultSignature, setDefaultSignature] = useState();
|
|
371
363
|
useEffect(() => {
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
if (!mailboxUid || contentReady) {
|
|
364
|
+
// Fetched once, for whichever mailbox is the sender when compose opens - switching From later must not
|
|
365
|
+
// overwrite what the user has already written (the editor itself never re-syncs).
|
|
366
|
+
if (!mailboxUid || contentReady || defaultSignature) {
|
|
375
367
|
return;
|
|
376
368
|
}
|
|
377
369
|
listMailSignatures(mailboxUid)
|
|
378
370
|
.then((signatures) => {
|
|
379
371
|
const signature = signatures.find((s) => signatureContext === "new" ? s.isDefaultForNewMessages : s.isDefaultForReplyForward);
|
|
380
|
-
|
|
381
|
-
setHtml(seeded);
|
|
382
|
-
setSeededHtml(seeded);
|
|
383
|
-
})
|
|
384
|
-
.catch(() => {
|
|
385
|
-
const seeded = buildComposeBodyHtml(undefined, initialQuotedHtml);
|
|
386
|
-
setHtml(seeded);
|
|
387
|
-
setSeededHtml(seeded);
|
|
372
|
+
setDefaultSignature({ html: signature?.contentHtml });
|
|
388
373
|
})
|
|
389
|
-
.
|
|
390
|
-
}, [mailboxUid, signatureContext
|
|
374
|
+
.catch(() => setDefaultSignature({}));
|
|
375
|
+
}, [mailboxUid, signatureContext]);
|
|
376
|
+
const [seededWithoutQuote, setSeededWithoutQuote] = useState(false);
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
if (contentReady || !defaultSignature) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const seeded = buildComposeBodyHtml(defaultSignature.html, quotePending ? undefined : initialQuotedHtml);
|
|
382
|
+
setHtml(seeded);
|
|
383
|
+
setSeededHtml(seeded);
|
|
384
|
+
setSeededWithoutQuote(!!quotePending);
|
|
385
|
+
setContentReady(true);
|
|
386
|
+
markComposePhase(id, "body");
|
|
387
|
+
}, [defaultSignature, quotePending, initialQuotedHtml]);
|
|
388
|
+
/** What the editor adds to the end of its document once the quote a reply was opened without has arrived. */
|
|
389
|
+
const lateQuoteHtml = seededWithoutQuote && !quotePending && initialQuotedHtml && defaultSignature
|
|
390
|
+
? buildComposeBodyHtml(defaultSignature.html, initialQuotedHtml).slice(buildComposeBodyHtml(defaultSignature.html) === "" ? "<p></p>".length : buildComposeBodyHtml(defaultSignature.html).length)
|
|
391
|
+
: undefined;
|
|
391
392
|
useEffect(() => {
|
|
392
393
|
if (!mailboxUid || !draftsFolderUid || draft) {
|
|
393
394
|
return;
|
|
@@ -405,6 +406,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
405
406
|
return;
|
|
406
407
|
}
|
|
407
408
|
setDraft(created);
|
|
409
|
+
markComposePhase(id, "draft");
|
|
408
410
|
// Only now that the replacement exists is it safe to discard the draft(s) a From switch
|
|
409
411
|
// superseded - if creating this one had failed, the earlier draft is still there.
|
|
410
412
|
for (const superseded of supersededDraftsRef.current.splice(0)) {
|
|
@@ -491,9 +493,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
491
493
|
discoveryGenerationRef.current += 1;
|
|
492
494
|
lookupsInFlightRef.current = new Set();
|
|
493
495
|
lookupFailuresRef.current = new Map();
|
|
494
|
-
setExhaustedLookups({});
|
|
495
496
|
setEncryptRequested(!!initialEncrypt);
|
|
496
|
-
setEncryptionBlocked(null);
|
|
497
497
|
setSecurityBlock(null);
|
|
498
498
|
offeredCryptoRef.current = { sign: false, encrypt: false };
|
|
499
499
|
setFromMailboxUid(nextMailboxUid);
|
|
@@ -549,6 +549,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
549
549
|
setAttachError("Please wait for the draft to finish loading before inserting an image.");
|
|
550
550
|
return null;
|
|
551
551
|
}
|
|
552
|
+
uploadStarted();
|
|
552
553
|
try {
|
|
553
554
|
const attachment = await uploadAttachment(draft.uid, file);
|
|
554
555
|
setHasUploads(true);
|
|
@@ -559,6 +560,21 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
559
560
|
setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload image.");
|
|
560
561
|
return null;
|
|
561
562
|
}
|
|
563
|
+
finally {
|
|
564
|
+
uploadFinished();
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
/** Send waits for every upload started before it: see `submit()`. */
|
|
568
|
+
function uploadStarted() {
|
|
569
|
+
uploadsInFlightRef.current += 1;
|
|
570
|
+
}
|
|
571
|
+
function uploadFinished() {
|
|
572
|
+
uploadsInFlightRef.current -= 1;
|
|
573
|
+
if (uploadsInFlightRef.current === 0) {
|
|
574
|
+
for (const resolve of uploadWaitersRef.current.splice(0)) {
|
|
575
|
+
resolve();
|
|
576
|
+
}
|
|
577
|
+
}
|
|
562
578
|
}
|
|
563
579
|
/** Uploading an attachment bumps the draft's version server-side (it records `hasAttachments`), so a later
|
|
564
580
|
* Discard would otherwise send a stale `?version=`. Best-effort: Discard also retries with the server's
|
|
@@ -581,198 +597,110 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
581
597
|
const files = Array.from(e.target.files ?? []);
|
|
582
598
|
e.target.value = "";
|
|
583
599
|
setAttachError(null);
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
/**
|
|
597
|
-
* Builds this draft's final body — plaintext, signed-only, or signed+encrypted per
|
|
598
|
-
* `specs/end-to-end_encryption.md` — then stores it via `assembleDraft()`/`assembleDraftRaw()`.
|
|
599
|
-
* Shared by immediate and scheduled sends (and the banners' override buttons rendered below, via
|
|
600
|
-
* `forcePlaintext`) so the sign/encrypt decision lives in exactly one place.
|
|
601
|
-
*
|
|
602
|
-
* Returns `"blocked"` (never throws for this case) whenever the message can't go out the way the user
|
|
603
|
-
* asked for — never a silent downgrade to plaintext:
|
|
604
|
-
* encryption was requested (or auto-applies) but not every recipient can currently be encrypted to -
|
|
605
|
-
* the spec's "Multiple Recipients" all-or-nothing rule (sets `encryptionBlocked`); signing/encryption was
|
|
606
|
-
* on offer this session but the keys have since locked, or the mailbox/encryption policy needed to decide
|
|
607
|
-
* couldn't be loaded (sets `securityBlock`, and re-prompts the unlock dialog when the keys are merely
|
|
608
|
-
* locked); or the message would be encrypted but has Bcc recipients - every recipient's certificate goes
|
|
609
|
-
* into one shared envelope, which would disclose the Bcc list to every other recipient, and the send API
|
|
610
|
-
* has no per-recipient-copy submission to avoid that (sets `securityBlock`).
|
|
611
|
-
* Callers must stop (not send) when they get this back. `forcePlaintext` (the user's explicit choice
|
|
612
|
-
* from one of those banners) skips every one of these checks and never encrypts; signing still
|
|
613
|
-
* happens if it's currently possible.
|
|
614
|
-
*
|
|
615
|
-
* Discovery (`lookupKeys()`) and the resulting sign/encrypt decision only run at this, the final
|
|
616
|
-
* pre-send step — `checkRecipientDiscovery()`'s on-blur badges are a best-effort hint only.
|
|
617
|
-
*/
|
|
618
|
-
async function assembleForSend(toRecipients, ccRecipients, bccRecipients, forcePlaintext) {
|
|
619
|
-
// Keys are read at the moment they're needed, and again after any await: they can lock (idle
|
|
620
|
-
// timeout, sign-out elsewhere) mid-send, and a destroyed `UnlockedKeys` object must count as locked.
|
|
621
|
-
// Only reachable once a draft exists, which itself requires `mailboxUid` to have resolved.
|
|
622
|
-
function readKeys() {
|
|
623
|
-
const stored = getUnlockedKeys(mailboxUid);
|
|
624
|
-
const unlocked = stored?.destroyed ? undefined : stored;
|
|
625
|
-
return {
|
|
626
|
-
unlocked,
|
|
627
|
-
// `mailbox` is required to build protected headers (own From address) whenever signing or
|
|
628
|
-
// encrypting - not just guarded on the encryption branch below, since a signed-only message
|
|
629
|
-
// needs it too.
|
|
630
|
-
canSign: signEnabled && !!mailbox && !!unlocked?.signingPrivateKey && !!unlocked.signingCertDer,
|
|
631
|
-
canEncryptSelf: !!unlocked?.encryptionPrivateKey && !!unlocked.encryptionCertDer,
|
|
632
|
-
};
|
|
633
|
-
}
|
|
634
|
-
let keys = readKeys();
|
|
635
|
-
const allRecipients = [...toRecipients, ...ccRecipients, ...bccRecipients];
|
|
636
|
-
// The Sign toggle was shown (and is still checked), but signing can't actually happen any more.
|
|
637
|
-
const signingLost = () => !forcePlaintext && signEnabled && offeredCryptoRef.current.sign && !keys.canSign;
|
|
638
|
-
if (signingLost()) {
|
|
639
|
-
return blockSend(KEYS_LOCKED_SIGN_MESSAGE, "Send without signing or encryption", !keys.unlocked);
|
|
640
|
-
}
|
|
641
|
-
if (!forcePlaintext && !mailbox) {
|
|
642
|
-
// The mailbox's enrolled keys are what say whether this message could be encrypted at all - with keys
|
|
643
|
-
// that aren't unlocked in this session, a failed mailbox load would otherwise skip encryption silently.
|
|
644
|
-
return blockSend(POLICY_UNAVAILABLE_MESSAGE, "Send without encryption", false);
|
|
645
|
-
}
|
|
646
|
-
let wantEncrypt = false;
|
|
647
|
-
let recipientCertDers = [];
|
|
648
|
-
// Entered whenever this message could be encrypted at all - including a mailbox with an enrolled
|
|
649
|
-
// encryption key that this session never unlocked: policy may still auto-encrypt, and that must block
|
|
650
|
-
// (and prompt to unlock) rather than quietly send plaintext.
|
|
651
|
-
if (!forcePlaintext && (keys.canEncryptSelf || offeredCryptoRef.current.encrypt || hasEnrolledEncryptionKey)) {
|
|
652
|
-
if (!encryptionPolicy) {
|
|
653
|
-
// Without it, there's no telling whether policy auto-encrypts this message.
|
|
654
|
-
return blockSend(POLICY_UNAVAILABLE_MESSAGE, "Send without encryption", false);
|
|
655
|
-
}
|
|
656
|
-
// Reachable only with `mailbox` loaded (checked above).
|
|
657
|
-
const ownPrefersMutual = mailbox.encryptPreference?.preferEncrypt === "mutual";
|
|
658
|
-
const lookups = await Promise.all(allRecipients.map((r) => lookupKeys(mailboxUid, r.address).then((lookup) => ({ lookup }), () => null)));
|
|
659
|
-
keys = readKeys();
|
|
660
|
-
if (signingLost()) {
|
|
661
|
-
return blockSend(KEYS_LOCKED_SIGN_MESSAGE, "Send without signing or encryption", !keys.unlocked);
|
|
662
|
-
}
|
|
663
|
-
// A failed lookup isn't "no key": treating it as one could send plaintext that policy would encrypt.
|
|
664
|
-
if (lookups.some((result) => !result) && (encryptRequested || policyCanAutoEncrypt(encryptionPolicy))) {
|
|
665
|
-
return blockSend(LOOKUP_UNAVAILABLE_MESSAGE, "Send without encryption", false);
|
|
666
|
-
}
|
|
667
|
-
const statuses = allRecipients.map((r, i) => resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, r.address, lookups[i]?.lookup));
|
|
668
|
-
const decision = decideMessageEncryption(statuses);
|
|
669
|
-
wantEncrypt = encryptRequested || decision.autoEncrypt;
|
|
670
|
-
if (wantEncrypt) {
|
|
671
|
-
if (!keys.canEncryptSelf) {
|
|
672
|
-
return blockSend(KEYS_LOCKED_ENCRYPT_MESSAGE, "Send without encryption", !keys.unlocked);
|
|
673
|
-
}
|
|
674
|
-
if (!decision.canEncryptAll) {
|
|
675
|
-
setEncryptionBlocked(decision.blockedRecipients);
|
|
676
|
-
return "blocked";
|
|
600
|
+
uploadStarted();
|
|
601
|
+
try {
|
|
602
|
+
for (const file of files) {
|
|
603
|
+
try {
|
|
604
|
+
const attachment = await uploadAttachment(draft.uid, file);
|
|
605
|
+
setHasUploads(true);
|
|
606
|
+
setAttachments((prev) => [...prev, attachment]);
|
|
607
|
+
void refreshDraftVersion(draft.uid);
|
|
677
608
|
}
|
|
678
|
-
|
|
679
|
-
|
|
609
|
+
catch (err) {
|
|
610
|
+
setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
|
|
680
611
|
}
|
|
681
|
-
recipientCertDers = [keys.unlocked.encryptionCertDer, ...statuses.map((s) => fromBase64(s.encryptCert.publicKey))];
|
|
682
612
|
}
|
|
683
613
|
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
// Matches BaseMailComposeRoute.assembleRaw()'s own server-side rejection, surfaced here with a
|
|
687
|
-
// clearer explanation than that route's generic 400 rather than via a round trip.
|
|
688
|
-
throw new ApiRequestError("A signed or encrypted message cannot include file attachments yet - remove them before sending.", 400);
|
|
689
|
-
}
|
|
690
|
-
if ((wantEncrypt || canSign) && INLINE_IMAGE_PATTERN.test(html)) {
|
|
691
|
-
throw new ApiRequestError("A signed or encrypted message cannot include inline images yet - remove them before sending.", 400);
|
|
692
|
-
}
|
|
693
|
-
if (!wantEncrypt && !canSign) {
|
|
694
|
-
// See the old compose page's identical note: `sanitize-html` is Node-oriented and the server-side
|
|
695
|
-
// gate in `BaseMailComposeRoute.assemble()` is the sole authoritative sanitizer regardless, so no
|
|
696
|
-
// client-side pass is done here either.
|
|
697
|
-
return assembleDraft(draft.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject, html });
|
|
698
|
-
}
|
|
699
|
-
const domain = mailbox.primarySmtpAddress.split("@")[1] ?? "localhost";
|
|
700
|
-
const displayName = mailbox.displayName && !UNSAFE_DISPLAY_NAME_PATTERN.test(mailbox.displayName) ? mailbox.displayName : "";
|
|
701
|
-
const protectedHeaders = {
|
|
702
|
-
from: displayName
|
|
703
|
-
? `"${displayName.replace(/"/g, '\\"')}" <${mailbox.primarySmtpAddress}>`
|
|
704
|
-
: mailbox.primarySmtpAddress,
|
|
705
|
-
to: toRecipients.map((r) => r.address).join(", "),
|
|
706
|
-
cc: ccRecipients.length > 0 ? ccRecipients.map((r) => r.address).join(", ") : undefined,
|
|
707
|
-
date: new Date().toUTCString(),
|
|
708
|
-
subject,
|
|
709
|
-
messageId: `<${crypto.randomUUID()}@${domain}>`,
|
|
710
|
-
};
|
|
711
|
-
const bodyContentType = 'text/html; charset="utf-8"';
|
|
712
|
-
const signing = canSign ? { certDer: keys.unlocked.signingCertDer, privateKey: keys.unlocked.signingPrivateKey } : undefined;
|
|
713
|
-
const outerHeaders = wantEncrypt ? applyBaselineOuterHeaders(protectedHeaders) : protectedHeaders;
|
|
714
|
-
const mimePart = wantEncrypt
|
|
715
|
-
? await buildEncryptedMessage(bodyContentType, html, protectedHeaders, outerHeaders, recipientCertDers, signing)
|
|
716
|
-
: await buildSignedOnlyMessage(bodyContentType, html, protectedHeaders, signing.certDer, signing.privateKey);
|
|
717
|
-
const rawMime = assembleOutboundMime(outerHeaders, mimePart);
|
|
718
|
-
return assembleDraftRaw(draft.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject: outerHeaders.subject, rawMime });
|
|
719
|
-
}
|
|
720
|
-
/** Records why a send was refused (see `assembleForSend()`), re-prompting the unlock dialog when the
|
|
721
|
-
* cause is keys that are enrolled but locked. */
|
|
722
|
-
function blockSend(message, overrideLabel, keysLocked) {
|
|
723
|
-
setSecurityBlock({ message, overrideLabel });
|
|
724
|
-
if (keysLocked && (hasEnrolledSigningKey || hasEnrolledEncryptionKey)) {
|
|
725
|
-
void handleUnlockForCrypto();
|
|
614
|
+
finally {
|
|
615
|
+
uploadFinished();
|
|
726
616
|
}
|
|
727
|
-
return "blocked";
|
|
728
617
|
}
|
|
729
618
|
/**
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
736
|
-
*
|
|
619
|
+
* Send (or, given `scheduledSendTimeIso`, Send later) - and get out of the way. The window is the place the message is *written*; nothing
|
|
620
|
+
* about getting it to the server is waited for here.
|
|
621
|
+
*
|
|
622
|
+
* 1. **Validate synchronously**, from what the window already knows (no request): a recipient is required; and if the message is one
|
|
623
|
+
* that must be signed or encrypted and can't be right now (keys locked, Bcc on an encrypted message, a recipient with no key, an
|
|
624
|
+
* attachment that can't be signed) that is shown right here and the window stays open - the sender fixes it or picks the override.
|
|
625
|
+
* An attachment still uploading is waited for, inline (Send says so), never left behind.
|
|
626
|
+
* 2. **Close immediately** (`onClose()`): `startSend()` (see `sendJob.ts`) takes over with a snapshot of the message. From here the only
|
|
627
|
+
* feedback is the Outbox indicator; a failure at any stage is a sticky pop-up with the reason and Retry / Open draft.
|
|
628
|
+
*
|
|
629
|
+
* Pressed twice (a double click, the shortcut repeated) it sends once: the first press sets `submittingRef` before any `await`, and
|
|
630
|
+
* `startSend()` itself refuses a draft that is already being sent. `forcePlaintext` is the override buttons' choice.
|
|
737
631
|
*/
|
|
738
632
|
async function submit(forcePlaintext, scheduledSendTimeIso) {
|
|
739
|
-
|
|
740
|
-
|
|
633
|
+
if (submittingRef.current || finishedRef.current) {
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (parseAddresses(latestRef.current.to).length === 0) {
|
|
741
637
|
setSendError("At least one recipient is required.");
|
|
742
638
|
return;
|
|
743
639
|
}
|
|
744
|
-
|
|
745
|
-
sendingRef.current = true;
|
|
640
|
+
submittingRef.current = true;
|
|
746
641
|
setSendError(null);
|
|
747
|
-
setSendDetails([]);
|
|
748
642
|
setSecurityBlock(null);
|
|
749
|
-
setEncryptionBlocked(null);
|
|
750
|
-
pendingSaveRef.current = false;
|
|
751
643
|
try {
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
644
|
+
if (uploadsInFlightRef.current > 0) {
|
|
645
|
+
setWaitingForUploads(true);
|
|
646
|
+
await new Promise((resolve) => uploadWaitersRef.current.push(resolve));
|
|
647
|
+
// The editor puts an image it has just uploaded into the message a moment after the upload resolves: let the window catch up.
|
|
648
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
649
|
+
setWaitingForUploads(false);
|
|
650
|
+
}
|
|
651
|
+
const current = latestRef.current;
|
|
652
|
+
const request = {
|
|
653
|
+
draft: current.draft,
|
|
654
|
+
mailboxUid: current.mailboxUid,
|
|
655
|
+
mailbox: current.mailbox ?? undefined,
|
|
656
|
+
policy: current.encryptionPolicy ?? undefined,
|
|
657
|
+
toText: current.to,
|
|
658
|
+
ccText: current.cc,
|
|
659
|
+
bccText: current.bcc,
|
|
660
|
+
to: parseAddresses(current.to),
|
|
661
|
+
cc: parseAddresses(current.cc),
|
|
662
|
+
bcc: parseAddresses(current.bcc),
|
|
663
|
+
subject: current.subject,
|
|
664
|
+
html: current.html,
|
|
665
|
+
attachments: current.attachments,
|
|
666
|
+
requestReceipt: current.requestReceipt,
|
|
667
|
+
scheduledSendTime: scheduledSendTimeIso,
|
|
668
|
+
signEnabled: current.signEnabled,
|
|
669
|
+
offeredSign: offeredCryptoRef.current.sign,
|
|
670
|
+
offeredEncrypt: offeredCryptoRef.current.encrypt,
|
|
671
|
+
encryptRequested: current.encryptRequested,
|
|
672
|
+
forcePlaintext,
|
|
673
|
+
saved: saveInFlightRef.current,
|
|
674
|
+
};
|
|
675
|
+
if (request.to.length === 0) {
|
|
676
|
+
setSendError("At least one recipient is required.");
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const early = decideSend(sendDecisionInput(request, request.mailbox, request.policy, current.statuses, false));
|
|
680
|
+
if (early.action === "rejected") {
|
|
681
|
+
setSendError(early.message);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
if (early.action === "blocked") {
|
|
756
685
|
setBlockedScheduleIso(scheduledSendTimeIso);
|
|
686
|
+
setSecurityBlock(early.block);
|
|
687
|
+
if (early.block.keysLocked && (hasEnrolledSigningKey || hasEnrolledEncryptionKey)) {
|
|
688
|
+
void handleUnlockForCrypto();
|
|
689
|
+
}
|
|
757
690
|
return;
|
|
758
691
|
}
|
|
759
|
-
|
|
760
|
-
// plaintext copy over it if the rest of the send fails.
|
|
761
|
-
lastSavedRef.current = `${assembled.uid}:${contentKey}`;
|
|
762
|
-
const withReceipt = requestReceipt ? await setMessageRequestReceipt(assembled, true) : assembled;
|
|
763
|
-
await sendMessage(withReceipt.uid, scheduledSendTimeIso ? { scheduledSendTime: scheduledSendTimeIso } : undefined);
|
|
692
|
+
pendingSaveRef.current = false;
|
|
764
693
|
finishedRef.current = true;
|
|
694
|
+
if (!startSend(request)) {
|
|
695
|
+
finishedRef.current = false;
|
|
696
|
+
setSendError("This message is already being sent.");
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
765
699
|
onClose();
|
|
766
700
|
}
|
|
767
|
-
catch (err) {
|
|
768
|
-
// The window stays open with the draft exactly as it was; nothing here closes, discards or saves over it.
|
|
769
|
-
const failure = describeSendFailure(err, scheduledSendTimeIso ? "Could not schedule this message." : "Could not send this message.");
|
|
770
|
-
setSendError(failure.message);
|
|
771
|
-
setSendDetails(failure.lines);
|
|
772
|
-
}
|
|
773
701
|
finally {
|
|
774
|
-
|
|
775
|
-
|
|
702
|
+
submittingRef.current = false;
|
|
703
|
+
setWaitingForUploads(false);
|
|
776
704
|
}
|
|
777
705
|
}
|
|
778
706
|
function handleScheduleSend(scheduledSendTimeIso) {
|
|
@@ -782,7 +710,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
782
710
|
async function handleUnlockForCrypto() {
|
|
783
711
|
try {
|
|
784
712
|
// Only reachable when hasEnrolledSigningKey/hasEnrolledEncryptionKey found a real enrolled key
|
|
785
|
-
// in mailbox.keys (the unlock button below, or
|
|
713
|
+
// in mailbox.keys (the unlock button below, or a blocked send()), so neither is empty/undefined here.
|
|
786
714
|
await requestUnlock(mailbox.uid, mailbox.keys);
|
|
787
715
|
setUnlockRefresh((n) => n + 1);
|
|
788
716
|
}
|
|
@@ -797,7 +725,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
797
725
|
* `latestRef` only once it actually runs, so it saves what was last typed (skipping the request when the
|
|
798
726
|
* save before it already stored exactly that). Whether a save may happen at all is re-checked at that same
|
|
799
727
|
* moment: while it waited, the message may have become one that must not be stored as plaintext (Encrypt
|
|
800
|
-
* turned on, a recipient that auto-encrypts), or the window may have
|
|
728
|
+
* turned on, a recipient that auto-encrypts), or the window may have been sent or finished - then it
|
|
801
729
|
* resolves `{ skipped: true }` without a request (`message` then being the previous save's result). Never
|
|
802
730
|
* rejects; `message` is `undefined` when the save failed.
|
|
803
731
|
*/
|
|
@@ -809,7 +737,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
809
737
|
const outcome = (async () => {
|
|
810
738
|
const before = await previous;
|
|
811
739
|
const current = latestRef.current;
|
|
812
|
-
if (current.autosaveSuppressed ||
|
|
740
|
+
if (current.autosaveSuppressed || finishedRef.current) {
|
|
813
741
|
setSaveStatus("idle");
|
|
814
742
|
// Still the newest copy the server holds, for the version a later delete needs.
|
|
815
743
|
return { skipped: true, message: before };
|
|
@@ -928,39 +856,19 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
928
856
|
}
|
|
929
857
|
}
|
|
930
858
|
/** "Close": keeps the draft (saving any unsaved edits first, and closing only once that save succeeded) -
|
|
931
|
-
* unless there's nothing in it worth keeping, or it can't be saved because it's headed for encryption
|
|
932
|
-
*
|
|
933
|
-
*
|
|
934
|
-
*
|
|
935
|
-
|
|
936
|
-
async function handleClose(sendFailureAcknowledged = false) {
|
|
859
|
+
* unless there's nothing in it worth keeping, or it can't be saved because it's really headed for encryption
|
|
860
|
+
* (asked for, or applied by the loaded policy): that needs the same confirmation as a discard. Whatever is
|
|
861
|
+
* *unknown* (settings loading or failed, a lookup pending) is not a reason to hold the window: it is saved like any
|
|
862
|
+
* other message. A failed save keeps the window open with Discard / Keep editing. */
|
|
863
|
+
async function handleClose() {
|
|
937
864
|
if (!hasUserContent) {
|
|
938
865
|
void discardNow();
|
|
939
866
|
return;
|
|
940
867
|
}
|
|
941
|
-
if (sendError && !sendFailureAcknowledged) {
|
|
942
|
-
// Closing would keep the draft and say nothing, which reads as if it had been sent.
|
|
943
|
-
setClosePrompt({ title: SEND_FAILED_TITLE, message: sendFailedCloseMessage(sendError), closeAnyway: true });
|
|
944
|
-
return;
|
|
945
|
-
}
|
|
946
868
|
if (encryptionDecided) {
|
|
947
869
|
setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
|
|
948
870
|
return;
|
|
949
871
|
}
|
|
950
|
-
if (cryptoCheckUnavailable) {
|
|
951
|
-
setClosePrompt({ title: DISCARD_TITLE, message: CRYPTO_UNAVAILABLE_MESSAGE, retry: true });
|
|
952
|
-
return;
|
|
953
|
-
}
|
|
954
|
-
if (awaitingRecipients) {
|
|
955
|
-
setClosePrompt({ title: DISCARD_TITLE, message: NO_RECIPIENTS_CLOSE_MESSAGE });
|
|
956
|
-
return;
|
|
957
|
-
}
|
|
958
|
-
if (encryptionUndetermined) {
|
|
959
|
-
// A recipient typed but never blurred has no lookup running yet.
|
|
960
|
-
checkRecipientDiscovery([...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]);
|
|
961
|
-
setClosePrompt({ title: DISCARD_TITLE, message: CHECKING_CLOSE_MESSAGE });
|
|
962
|
-
return;
|
|
963
|
-
}
|
|
964
872
|
if (!draft || !contentReady) {
|
|
965
873
|
saveErrorRef.current = draftError ?? "the draft hasn't been created yet";
|
|
966
874
|
showSaveFailedPrompt();
|
|
@@ -972,7 +880,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
972
880
|
setClosing(false);
|
|
973
881
|
if (outcome.skipped) {
|
|
974
882
|
// It became a message that mustn't be saved as plaintext while waiting on an earlier save.
|
|
975
|
-
setClosePrompt({ title: DISCARD_TITLE, message:
|
|
883
|
+
setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
|
|
976
884
|
return;
|
|
977
885
|
}
|
|
978
886
|
if (!outcome.message) {
|
|
@@ -983,13 +891,6 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
983
891
|
finishedRef.current = true;
|
|
984
892
|
onClose();
|
|
985
893
|
}
|
|
986
|
-
function retryCryptoContext() {
|
|
987
|
-
setClosePrompt(null);
|
|
988
|
-
setCryptoRetryToken((n) => n + 1);
|
|
989
|
-
lookupFailuresRef.current = new Map();
|
|
990
|
-
setExhaustedLookups({});
|
|
991
|
-
setLookupRetryToken((n) => n + 1);
|
|
992
|
-
}
|
|
993
894
|
// A plain read from keySession.ts's module-level session store, not React state - see that module's
|
|
994
895
|
// own doc comment. Cheap enough to read fresh on every render rather than caching in state.
|
|
995
896
|
const unlockedKeys = mailboxUid ? getUnlockedKeys(mailboxUid) : undefined;
|
|
@@ -1019,34 +920,85 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1019
920
|
// Autosave. "User content" is anything beyond what compose opened with (prefilled recipients/subject,
|
|
1020
921
|
// seeded signature/quote) - an untouched window has nothing worth saving or confirming a discard of.
|
|
1021
922
|
const contentKey = JSON.stringify([to, cc, bcc, subject, html]);
|
|
1022
|
-
const baselineKey = JSON.stringify([
|
|
923
|
+
const baselineKey = JSON.stringify([baselineTo ?? "", baselineCc ?? "", "", initialSubject ?? "", seededHtml ?? ""]);
|
|
1023
924
|
const hasUserContent = contentKey !== baselineKey || hasUploads;
|
|
1024
|
-
//
|
|
1025
|
-
//
|
|
1026
|
-
//
|
|
1027
|
-
//
|
|
1028
|
-
//
|
|
1029
|
-
//
|
|
925
|
+
// Whether this message is *really* headed for encryption - `encryptionRequirement.ts` decides, and it fails open: the message is plain (its
|
|
926
|
+
// draft saves, Close and Send work, nothing is shown) unless the user asked for encryption or the policy was loaded and applies it to every
|
|
927
|
+
// recipient (whose keys were all looked up). What has not loaded, or failed to, is not held against the message.
|
|
928
|
+
//
|
|
929
|
+
// Transitions, when a later load turns a plain message into an encrypted one (the policy arrives, a slow lookup answers, a recipient is
|
|
930
|
+
// added): (1) autosave stops at once (`autosaveSuppressed`); (2) a plaintext copy this window already saved is *replaced by an empty draft*
|
|
931
|
+
// (`scrubSavedDraft()` below) so the plaintext doesn't sit on the server; (3) Close asks first ("Encrypted messages aren't saved as drafts");
|
|
932
|
+
// (4) Send encrypts (or is refused, with the override, when it can't) - never silently plaintext. And back: when it stops being encrypted
|
|
933
|
+
// (a recipient removed) autosave simply resumes.
|
|
1030
934
|
const currentAddresses = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)].map((r) => r.address);
|
|
1031
935
|
const currentStatuses = currentAddresses.map((address) => recipientStatuses[address]);
|
|
1032
|
-
const
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
const
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
936
|
+
const hasEncryptionKey = !!unlockedKeys?.encryptionPrivateKey || hasEnrolledEncryptionKey || offeredCryptoRef.current.encrypt;
|
|
937
|
+
const requirement = evaluateEncryptionRequirement({
|
|
938
|
+
encryptRequested,
|
|
939
|
+
policy: encryptionPolicy ?? undefined,
|
|
940
|
+
mailboxLoaded: !!mailbox,
|
|
941
|
+
hasEncryptionKey,
|
|
942
|
+
recipients: currentStatuses,
|
|
943
|
+
});
|
|
944
|
+
const encryptionDecided = requirement.required;
|
|
945
|
+
const autosaveSuppressed = encryptionDecided;
|
|
946
|
+
const latestRef = useRef({
|
|
947
|
+
draft, mailboxUid, mailbox, encryptionPolicy, to, cc, bcc, subject, html, attachments, requestReceipt, signEnabled, encryptRequested,
|
|
948
|
+
statuses: currentStatuses, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided,
|
|
949
|
+
});
|
|
950
|
+
latestRef.current = {
|
|
951
|
+
draft, mailboxUid, mailbox, encryptionPolicy, to, cc, bcc, subject, html, attachments, requestReceipt, signEnabled, encryptRequested,
|
|
952
|
+
statuses: currentStatuses, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided,
|
|
953
|
+
};
|
|
954
|
+
/** A message that turned out to need encryption after a plaintext copy of it was saved: the saved copy is replaced by an empty draft (the same
|
|
955
|
+
* uid, so attachments and the folder are undisturbed), chained after any save still on the wire. Best effort - a failure is reported once. */
|
|
956
|
+
function scrubSavedDraft(target) {
|
|
957
|
+
lastSavedRef.current = null;
|
|
958
|
+
setSaveStatus("idle");
|
|
959
|
+
const previous = saveInFlightRef.current;
|
|
960
|
+
const scrub = (async () => {
|
|
961
|
+
await previous;
|
|
962
|
+
try {
|
|
963
|
+
// Its answer (the new version) is what a later delete waits for through `saveInFlightRef`, like any save's.
|
|
964
|
+
return await assembleDraft(target.uid, { to: [], cc: [], bcc: [], subject: "", html: "" });
|
|
965
|
+
}
|
|
966
|
+
catch {
|
|
967
|
+
notify({
|
|
968
|
+
id: `scrub-draft:${target.uid}`,
|
|
969
|
+
kind: "warning",
|
|
970
|
+
title: "An earlier draft copy is still saved",
|
|
971
|
+
message: "This message needs encryption, but the copy saved before that was known couldn't be replaced. Discarding the draft removes it.",
|
|
972
|
+
});
|
|
973
|
+
return undefined;
|
|
974
|
+
}
|
|
975
|
+
})();
|
|
976
|
+
saveInFlightRef.current = scrub;
|
|
977
|
+
}
|
|
978
|
+
// Only a message that was really saved (`lastSavedRef` names its draft) has a plaintext copy to replace.
|
|
979
|
+
useEffect(() => {
|
|
980
|
+
if (encryptionDecided && draft && lastSavedRef.current?.startsWith(`${draft.uid}:`)) {
|
|
981
|
+
scrubSavedDraft(draft);
|
|
982
|
+
}
|
|
983
|
+
}, [encryptionDecided, draft?.uid]);
|
|
984
|
+
// Recipients a reply could only work out after this window opened (Reply All: whoever the original's headers name)
|
|
985
|
+
// replace the ones it opened with - but only in a field the user hasn't touched, never over what they typed.
|
|
986
|
+
useEffect(() => {
|
|
987
|
+
if (!late) {
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
const current = latestRef.current;
|
|
991
|
+
if (late.to !== undefined && current.to === (initialTo ?? "")) {
|
|
992
|
+
setTo(late.to);
|
|
993
|
+
}
|
|
994
|
+
if (late.cc !== undefined && current.cc === (initialCc ?? "")) {
|
|
995
|
+
setCc(late.cc);
|
|
996
|
+
setShowCcBcc((shown) => shown || late.cc !== "");
|
|
997
|
+
}
|
|
998
|
+
}, [late]);
|
|
1045
999
|
useEffect(() => {
|
|
1046
1000
|
const due = !!draft &&
|
|
1047
1001
|
contentReady &&
|
|
1048
|
-
cryptoContextReady &&
|
|
1049
|
-
!sending &&
|
|
1050
1002
|
!autosaveSuppressed &&
|
|
1051
1003
|
hasUserContent &&
|
|
1052
1004
|
lastSavedRef.current !== `${draft.uid}:${contentKey}`;
|
|
@@ -1056,7 +1008,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1056
1008
|
}
|
|
1057
1009
|
const timer = setTimeout(() => void saveDraftNow(), autosaveDelayMs);
|
|
1058
1010
|
return () => clearTimeout(timer);
|
|
1059
|
-
}, [draft, contentReady,
|
|
1011
|
+
}, [draft, contentReady, autosaveSuppressed, hasUserContent, contentKey]);
|
|
1060
1012
|
// Unmounting with an edit still waiting on the debounce (e.g. the whole app shell unmounting) saves it
|
|
1061
1013
|
// right away rather than losing it.
|
|
1062
1014
|
useEffect(() => () => {
|
|
@@ -1098,7 +1050,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1098
1050
|
// out - that navigation must not be cancellable.
|
|
1099
1051
|
useEffect(() => {
|
|
1100
1052
|
function handleBeforeUnload(event) {
|
|
1101
|
-
if (finishedRef.current ||
|
|
1053
|
+
if (finishedRef.current || isSigningOut()) {
|
|
1102
1054
|
return;
|
|
1103
1055
|
}
|
|
1104
1056
|
const latest = latestRef.current;
|
|
@@ -1118,7 +1070,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1118
1070
|
// Where the caret starts, once per session: a reply/forward in the body (at its top), a new message in To - or,
|
|
1119
1071
|
// with To already filled in (e.g. Contacts' "Email"), in Subject. Minimizing unmounts the fields, so restoring a
|
|
1120
1072
|
// window must not move the caret there again.
|
|
1121
|
-
const isReplyOrForward = initialQuotedHtml !== undefined;
|
|
1073
|
+
const isReplyOrForward = initialQuotedHtml !== undefined || !!quotePending || !!resume;
|
|
1122
1074
|
const fieldsFocusedRef = useRef(false);
|
|
1123
1075
|
const bodyFocusedRef = useRef(false);
|
|
1124
1076
|
const focusToOnMount = !isReplyOrForward && !initialTo && !fieldsFocusedRef.current;
|
|
@@ -1127,7 +1079,16 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1127
1079
|
// The editor serializes the seeded body its own way (see `RichTextEditor`'s `onInitialized`). While the body is
|
|
1128
1080
|
// still exactly what compose seeded, that serialization becomes the baseline - so an untouched reply or signature
|
|
1129
1081
|
// isn't mistaken for an edit (and autosaved) just because the editor was focused or clicked.
|
|
1082
|
+
/** The quote has been added: unless the reader has typed something already, that is still exactly what compose seeded. */
|
|
1083
|
+
function handleQuoteAppended(normalized) {
|
|
1084
|
+
const latest = latestRef.current;
|
|
1085
|
+
if (latest.html === latest.seededHtml) {
|
|
1086
|
+
setHtml(normalized);
|
|
1087
|
+
setSeededHtml(normalized);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1130
1090
|
function handleEditorInitialized(normalized) {
|
|
1091
|
+
markComposePhase(id, "editor");
|
|
1131
1092
|
const latest = latestRef.current;
|
|
1132
1093
|
if (latest.html === latest.seededHtml && normalized !== latest.html) {
|
|
1133
1094
|
setHtml(normalized);
|
|
@@ -1140,21 +1101,37 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1140
1101
|
bodyFocusedRef.current || (bodyFocusedRef.current = contentReady);
|
|
1141
1102
|
}
|
|
1142
1103
|
}, [minimized, contentReady]);
|
|
1104
|
+
// Keyboard shortcuts, active while the focus is inside this window (`container`; the scope attribute on its root is what tells the
|
|
1105
|
+
// 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 -
|
|
1106
|
+
// a shortcut pressed while its button would be disabled is consumed and does nothing.
|
|
1107
|
+
// Send never waits for the encryption settings to load (fail open, see `encryptionRequirement.ts`); it only needs the draft to exist. Pressed
|
|
1108
|
+
// while an earlier press is still being handled it does nothing (`submit()` guards that itself).
|
|
1109
|
+
const canSend = !!draft && !closing && !waitingForUploads;
|
|
1110
|
+
useShortcut(SHORTCUTS.compose.send, () => void (canSend && submit(false)), { container: windowRef });
|
|
1111
|
+
useShortcut(SHORTCUTS.compose.saveDraft, () => {
|
|
1112
|
+
// Autosave's own conditions: never a draft that must not be stored as plaintext.
|
|
1113
|
+
if (draft && contentReady && !closing && !autosaveSuppressed && hasUserContent) {
|
|
1114
|
+
void saveDraftNow();
|
|
1115
|
+
}
|
|
1116
|
+
}, { container: windowRef });
|
|
1117
|
+
useShortcut(SHORTCUTS.compose.close, () => {
|
|
1118
|
+
if (!closing) {
|
|
1119
|
+
void handleClose();
|
|
1120
|
+
}
|
|
1121
|
+
}, { container: windowRef });
|
|
1122
|
+
useShortcut(SHORTCUTS.compose.create, () => openCompose({ mailboxUid }), { container: windowRef });
|
|
1143
1123
|
const title = subject.trim() || "New Message";
|
|
1144
1124
|
const titleId = `compose-title-${id}`;
|
|
1145
|
-
const discardModal = (_jsxs(Modal, { open: closePrompt !== null, onClose: () => setClosePrompt(null), title: closePrompt?.title ?? DISCARD_TITLE, children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: closePrompt?.message }), _jsxs("div", { className: "flex gap-3", children: [
|
|
1146
|
-
setClosePrompt(null);
|
|
1147
|
-
void handleClose(true);
|
|
1148
|
-
}, disabled: sending || closing, className: "!w-auto", children: "Close, keep draft" })), _jsx(Button, { type: "button", onClick: () => void discardNow(), disabled: sending || closing, className: "!w-auto", children: "Discard" }), _jsx(Button, { type: "button", variant: "secondary", onClick: () => setClosePrompt(null), className: "!w-auto", children: "Keep editing" })] })] }));
|
|
1125
|
+
const discardModal = (_jsxs(Modal, { open: closePrompt !== null, onClose: () => setClosePrompt(null), title: closePrompt?.title ?? DISCARD_TITLE, children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: closePrompt?.message }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", onClick: () => void discardNow(), disabled: closing, className: "!w-auto", children: "Discard" }), _jsx(Button, { type: "button", variant: "secondary", onClick: () => setClosePrompt(null), className: "!w-auto", children: "Keep editing" })] })] }));
|
|
1149
1126
|
if (minimized) {
|
|
1150
|
-
return (_jsxs(_Fragment, { children: [_jsx("div", { role: "dialog", "aria-label": title, className: "w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal", children: _jsxs("div", { className: "h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { className: "text-sm font-medium truncate", children: title }),
|
|
1127
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { role: "dialog", "aria-label": title, className: "w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal", children: _jsxs("div", { className: "h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Restore", onClick: onToggleMinimize, icon: HiOutlineArrowsPointingOut }), _jsx(HeaderButton, { label: "Discard draft", onClick: handleDiscard, icon: HiOutlineXMark, disabled: closing })] })] }) }), discardModal] }));
|
|
1151
1128
|
}
|
|
1152
|
-
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: windowRef, role: "dialog", "aria-labelledby": titleId, style: !isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined, className: [
|
|
1129
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: windowRef, role: "dialog", "data-shortcut-scope": "compose", "aria-labelledby": titleId, style: !isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined, className: [
|
|
1153
1130
|
"relative shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
|
|
1154
1131
|
isMobile
|
|
1155
1132
|
? "fixed inset-0 w-full h-full rounded-none border-0"
|
|
1156
1133
|
: ["border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
|
|
1157
|
-
].join(" "), children: [!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 cursor-pointer", onClick: 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: [_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus }), !isMobile && (_jsx(HeaderButton, { label: expanded ? "Collapse" : "Expand", onClick: toggleExpanded, icon: expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut })), _jsx(HeaderButton, { label: "Close", onClick: () => void handleClose(), icon: HiOutlineXMark, disabled:
|
|
1134
|
+
].join(" "), children: [!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 cursor-pointer", onClick: 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: [_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus }), !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`, 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)) })] })] })), (() => {
|
|
1158
1135
|
const knownRecipients = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]
|
|
1159
1136
|
.map((r) => recipientStatuses[r.address])
|
|
1160
1137
|
.filter((status) => !!status);
|
|
@@ -1162,5 +1139,5 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1162
1139
|
return null;
|
|
1163
1140
|
}
|
|
1164
1141
|
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))) }));
|
|
1165
|
-
})(), _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, { value: html, onChange: setHtml, fill: true, onUploadImage: handleUploadImage, autoFocusStart: focusBodyOnMount, onInitialized: handleEditorInitialized })) }), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 px-3 pb-2", children: attachments.map((attachment) => (_jsx("li", { className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted", children: attachment.filename }, 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), disabled: !draft ||
|
|
1142
|
+
})(), _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 })) }), 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) => (_jsx("li", { className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted", children: attachment.filename }, 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: !draft || closing || waitingForUploads, 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: !draft || closing || waitingForUploads, 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("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] }));
|
|
1166
1143
|
}
|