@rapidmx/web-client 0.10.0 → 0.12.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 +183 -2
- 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/mailboxes/new/index.tsx +3 -3
- 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/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +4 -4
- 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 +60 -14
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +65 -16
- 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/contacts/ContactForm.tsx +11 -5
- package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -3
- package/apps/shared/components/contacts/ContactsToolbar.tsx +25 -52
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +10 -5
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +13 -6
- package/apps/shared/components/layout/AppShell.tsx +98 -32
- package/apps/shared/components/layout/BrandingChrome.tsx +200 -13
- 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 +75 -6
- package/apps/shared/components/mail/ConversationList.tsx +4 -2
- package/apps/shared/components/mail/ConversationThreadPane.tsx +487 -475
- package/apps/shared/components/mail/LazyReadingPane.tsx +14 -16
- package/apps/shared/components/mail/MailSelectionBar.tsx +2 -10
- package/apps/shared/components/mail/MessageDetailPane.tsx +366 -287
- 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 +35 -2
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +239 -437
- package/apps/shared/components/mail/compose/encryptionRequirement.ts +86 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +39 -25
- 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/settings/SigningCertificateCard.tsx +344 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/components/sharing/PrincipalListField.tsx +82 -0
- package/apps/shared/components/sharing/PrincipalPicker.tsx +76 -0
- package/apps/shared/components/sharing/PrincipalResolver.tsx +164 -0
- package/apps/shared/components/tasks/TasksSidebar.tsx +5 -3
- package/apps/shared/components/tasks/layout/TasksShell.tsx +9 -5
- package/apps/shared/mail/folderCounts.ts +9 -3
- package/apps/shared/mail/folderTree.ts +143 -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 +81 -28
- package/apps/shared/mail/useMailLiveUpdates.ts +55 -10
- package/apps/shared/mail/useNewMailNotifications.ts +47 -32
- package/apps/shared/navigation/AppRouter.tsx +2 -2
- 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/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 +80 -0
- package/apps/www/_layout.tsx +8 -2
- package/apps/www/_routes.ts +1 -0
- package/apps/www/calendar/index.tsx +9 -14
- package/apps/www/contacts/[uid].tsx +5 -8
- package/apps/www/contacts/index.tsx +587 -584
- package/apps/www/index.tsx +38 -27
- package/apps/www/messages/[uid].tsx +3 -1
- package/apps/www/settings/appearance/index.tsx +21 -0
- package/apps/www/settings/auto-reply/index.tsx +3 -7
- package/apps/www/settings/encryption/index.tsx +151 -116
- package/apps/www/settings/filters/[uid].tsx +3 -1
- package/apps/www/settings/filters/new/index.tsx +3 -1
- package/apps/www/settings/privacy/index.tsx +3 -8
- package/apps/www/settings/read-receipts/index.tsx +3 -7
- package/apps/www/settings/sharing/index.tsx +44 -65
- package/apps/www/settings/signatures/[uid].tsx +3 -1
- package/apps/www/settings/signatures/new/index.tsx +3 -2
- package/apps/www/tasks/index.tsx +11 -16
- 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/mailboxes/new/index.js +1 -1
- 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/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +3 -2
- 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 +25 -5
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +18 -7
- 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/contacts/ContactForm.js +1 -1
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +4 -2
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +24 -9
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -1
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +7 -4
- package/dist/apps/shared/components/layout/AppShell.d.ts +6 -1
- package/dist/apps/shared/components/layout/AppShell.js +59 -13
- 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/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 +7 -1
- package/dist/apps/shared/components/layout/UserMenu.js +41 -7
- package/dist/apps/shared/components/mail/ConversationList.js +3 -2
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +2 -1
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +42 -20
- package/dist/apps/shared/components/mail/LazyReadingPane.js +9 -8
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +3 -4
- package/dist/apps/shared/components/mail/MailSelectionBar.js +2 -3
- package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +18 -7
- package/dist/apps/shared/components/mail/MessageDetailPane.js +86 -41
- 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 +8 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +28 -3
- package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +2 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +229 -328
- 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/layout/MailShell.d.ts +2 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +20 -14
- 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/settings/SigningCertificateCard.d.ts +32 -0
- package/dist/apps/shared/components/settings/SigningCertificateCard.js +98 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/components/sharing/PrincipalListField.d.ts +24 -0
- package/dist/apps/shared/components/sharing/PrincipalListField.js +23 -0
- package/dist/apps/shared/components/sharing/PrincipalPicker.d.ts +28 -0
- package/dist/apps/shared/components/sharing/PrincipalPicker.js +24 -0
- package/dist/apps/shared/components/sharing/PrincipalResolver.d.ts +44 -0
- package/dist/apps/shared/components/sharing/PrincipalResolver.js +92 -0
- package/dist/apps/shared/components/tasks/TasksSidebar.js +4 -2
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +3 -1
- package/dist/apps/shared/mail/folderCounts.d.ts +1 -1
- package/dist/apps/shared/mail/folderCounts.js +9 -3
- package/dist/apps/shared/mail/folderTree.d.ts +55 -0
- package/dist/apps/shared/mail/folderTree.js +116 -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 +9 -8
- package/dist/apps/shared/mail/useMailConnection.js +64 -22
- package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +17 -4
- package/dist/apps/shared/mail/useMailLiveUpdates.js +36 -10
- package/dist/apps/shared/mail/useNewMailNotifications.d.ts +4 -10
- package/dist/apps/shared/mail/useNewMailNotifications.js +43 -23
- package/dist/apps/shared/navigation/AppRouter.js +2 -2
- 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/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 +80 -0
- package/dist/apps/www/_layout.d.ts +5 -1
- package/dist/apps/www/_layout.js +3 -2
- package/dist/apps/www/_routes.js +1 -0
- package/dist/apps/www/calendar/index.js +3 -4
- package/dist/apps/www/contacts/[uid].js +5 -7
- package/dist/apps/www/contacts/index.js +14 -21
- package/dist/apps/www/index.js +37 -19
- package/dist/apps/www/messages/[uid].js +3 -1
- 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.js +3 -6
- package/dist/apps/www/settings/encryption/index.js +103 -95
- package/dist/apps/www/settings/filters/[uid].js +3 -1
- package/dist/apps/www/settings/filters/new/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.js +5 -8
- package/dist/apps/www/settings/read-receipts/index.js +3 -6
- package/dist/apps/www/settings/sharing/index.js +19 -33
- package/dist/apps/www/settings/signatures/[uid].js +3 -1
- package/dist/apps/www/settings/signatures/new/index.js +3 -2
- package/dist/apps/www/tasks/index.js +8 -14
- package/package.json +2 -2
- package/apps/shared/components/mail/NewMailToasts.tsx +0 -141
- package/apps/shared/components/mail/compose/SendFailureAlert.tsx +0 -48
- package/dist/apps/shared/components/mail/NewMailToasts.d.ts +0 -19
- package/dist/apps/shared/components/mail/NewMailToasts.js +0 -55
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +0 -14
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +0 -11
|
@@ -12,6 +12,8 @@ import { getBranding } from "@rapidmx/react-shared/branding/brandingApi.js";
|
|
|
12
12
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
13
13
|
import BottomTabBar, { NavItem } from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
|
|
14
14
|
import UserMenu from "../../layout/UserMenu.js";
|
|
15
|
+
import RailIcon from "../../layout/RailIcon.js";
|
|
16
|
+
import AppearanceProvider from "../../../appearance/AppearanceProvider.js";
|
|
15
17
|
import { signOutOfConsole } from "../../admin/signOut.js";
|
|
16
18
|
|
|
17
19
|
export type EscrowSection = "matters" | "auditLog";
|
|
@@ -104,13 +106,13 @@ export default function EscrowShell({ active, userUid, authServerUrl, children }
|
|
|
104
106
|
} else {
|
|
105
107
|
const activeItem = NAV_ITEMS.find((item) => item.id === active);
|
|
106
108
|
content = (
|
|
107
|
-
<div className="min-h-screen flex flex-col
|
|
109
|
+
<div className="rr-frame-bg min-h-screen flex flex-col">
|
|
108
110
|
<div className="flex-1 flex min-h-0">
|
|
109
111
|
<nav
|
|
110
112
|
aria-label="Escrow sections"
|
|
111
|
-
className="hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center
|
|
113
|
+
className="hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center pb-3 gap-1"
|
|
112
114
|
>
|
|
113
|
-
<
|
|
115
|
+
<RailIcon src={iconSrc} />
|
|
114
116
|
{NAV_ITEMS.map(({ id, href, label, icon: Icon }) => (
|
|
115
117
|
<a
|
|
116
118
|
key={id}
|
|
@@ -131,11 +133,11 @@ export default function EscrowShell({ active, userUid, authServerUrl, children }
|
|
|
131
133
|
</nav>
|
|
132
134
|
<BottomTabBar apps={NAV_ITEMS} active={active} />
|
|
133
135
|
<div className="flex-1 flex flex-col min-w-0">
|
|
134
|
-
<header className="h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6">
|
|
136
|
+
<header className="rr-solid sticky top-0 z-30 h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6">
|
|
135
137
|
<span className="font-display font-bold text-lg uppercase tracking-wide">{activeItem?.label}</span>
|
|
136
138
|
<UserMenu userUid={userUid} authServerUrl={authServerUrl} onSignOut={handleSignOut} />
|
|
137
139
|
</header>
|
|
138
|
-
<div className="flex-1 pb-14 md:pb-0">
|
|
140
|
+
<div id="app-content" className="flex-1 pb-14 md:pb-0">
|
|
139
141
|
<main className="max-w-6xl mx-auto px-6 py-8">{children}</main>
|
|
140
142
|
</div>
|
|
141
143
|
</div>
|
|
@@ -144,5 +146,10 @@ export default function EscrowShell({ active, userUid, authServerUrl, children }
|
|
|
144
146
|
);
|
|
145
147
|
}
|
|
146
148
|
|
|
147
|
-
|
|
149
|
+
// The user's own colours and background (`AppearanceProvider`), from what the last webmail page cached in this browser: the console never asks the server.
|
|
150
|
+
return (
|
|
151
|
+
<AppearanceProvider userUid={userUid} lookUp={false}>
|
|
152
|
+
{content}
|
|
153
|
+
</AppearanceProvider>
|
|
154
|
+
);
|
|
148
155
|
}
|
|
@@ -20,11 +20,15 @@ import { useIdleKeyTimeout } from "@rapidmx/react-shared/crypto/useIdleKeyTimeou
|
|
|
20
20
|
import ComposeProvider from "../mail/compose/ComposeContext.js";
|
|
21
21
|
import { flushComposeDrafts, markSigningOut } from "../mail/compose/composeFlushRegistry.js";
|
|
22
22
|
import BottomTabBar, { NavItem } from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
|
|
23
|
-
import {
|
|
23
|
+
import { FrameBrandingFooter, FrameBrandingHeader, useBrandingHtml } from "./BrandingChrome.js";
|
|
24
|
+
import RailIcon from "./RailIcon.js";
|
|
25
|
+
import AppearanceProvider from "../../appearance/AppearanceProvider.js";
|
|
26
|
+
import { clearAppearanceCache } from "../../appearance/appearanceCache.js";
|
|
27
|
+
import type { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
|
|
24
28
|
import UserMenu from "./UserMenu.js";
|
|
25
29
|
import { UnlockPromptProvider } from "./UnlockPromptProvider.js";
|
|
26
30
|
import { SIGN_OUT_CHANNEL, destroyAllLocalIndexes } from "../../search/localIndexRpcClient.js";
|
|
27
|
-
import { authApiFetch } from "@rapidmx/react-shared/util/api.js";
|
|
31
|
+
import { authApiFetch, setApiUnauthorizedObserver } from "@rapidmx/react-shared/util/api.js";
|
|
28
32
|
import { destroyUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
29
33
|
import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
|
|
30
34
|
import { mergePluginNavItems, PluginNav, PluginNavProps } from "../../plugins/pluginNav.js";
|
|
@@ -37,7 +41,13 @@ import ShortcutsDialog from "../../keyboard/ShortcutsDialog.js";
|
|
|
37
41
|
import { inboxUnreadTotal } from "../../mail/folderCounts.js";
|
|
38
42
|
import { MailConnectionContext, useMailConnection } from "../../mail/useMailConnection.js";
|
|
39
43
|
import { useUnreadTitle } from "../../mail/useUnreadTitle.js";
|
|
40
|
-
import
|
|
44
|
+
import UnlockBridge from "../../mail/outbox/UnlockBridge.js";
|
|
45
|
+
import NotificationCenter from "../../notifications/NotificationCenter.js";
|
|
46
|
+
import { useHeaderHeightRef } from "../../notifications/headerOffset.js";
|
|
47
|
+
import NotificationHistoryDialog from "../../notifications/NotificationHistoryDialog.js";
|
|
48
|
+
import { notifySessionExpired, setSignInUrl } from "../../notifications/apiErrors.js";
|
|
49
|
+
import { useUnseenErrorCount } from "../../notifications/useNotifications.js";
|
|
50
|
+
import { useSigningEnrollmentWatcher } from "../../signing/useSigningEnrollmentWatcher.js";
|
|
41
51
|
|
|
42
52
|
/** How long sign-out waits for auth-server's logout before navigating anyway. */
|
|
43
53
|
export const LOGOUT_TIMEOUT_MS = 3_000;
|
|
@@ -93,6 +103,10 @@ export interface AppShellProps extends PluginNavProps {
|
|
|
93
103
|
/** The role names the server treats as trusted (its `trusted_roles` config, injected via the route's `fetchProps`) -
|
|
94
104
|
* what that lookup looks for. Absent means the server's own default, `["admin"]`. */
|
|
95
105
|
trustedRoles?: string[];
|
|
106
|
+
/** The deployment's branding as the server rendered the page (its `branding` prop). Used until the frame's own fetch answers, so a custom header is there from the first paint - and the frame knows to drop its own title bar - instead of appearing (and moving everything) once the request returns. */
|
|
107
|
+
branding?: Branding;
|
|
108
|
+
/** The signed-in user's stored appearance preferences as the server rendered the page (its `appearance` prop), so the first paint already wears the theme and background - see `AppearanceProvider`. */
|
|
109
|
+
appearance?: unknown;
|
|
96
110
|
}
|
|
97
111
|
|
|
98
112
|
export interface AppDef {
|
|
@@ -152,6 +166,8 @@ export function AppChrome({
|
|
|
152
166
|
impersonationBaseUrl,
|
|
153
167
|
trusted,
|
|
154
168
|
trustedRoles,
|
|
169
|
+
branding: initialBranding,
|
|
170
|
+
appearance,
|
|
155
171
|
pluginNav,
|
|
156
172
|
routeKey,
|
|
157
173
|
busy,
|
|
@@ -161,8 +177,20 @@ export function AppChrome({
|
|
|
161
177
|
const [stoppingImpersonation, setStoppingImpersonation] = useState(false);
|
|
162
178
|
// The keyboard shortcuts dialog: opened by `?`/Ctrl+/ (`GlobalShortcuts`) and by the account menu's item.
|
|
163
179
|
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
|
180
|
+
// The pop-up history ("Recent notifications" in the account menu). The pop-ups themselves are drawn by `NotificationCenter`, below.
|
|
181
|
+
const [historyOpen, setHistoryOpen] = useState(false);
|
|
182
|
+
// Only the count is read, so the whole frame does not render again for every pop-up that comes and goes.
|
|
183
|
+
const unseenErrors = useUnseenErrorCount();
|
|
184
|
+
// The title bar's height for the pop-up stack, which sticks just below the header (a branding header publishes its own - `FrameBrandingHeader`).
|
|
185
|
+
const headerRef = useHeaderHeightRef();
|
|
164
186
|
const signingOutRef = useRef(false);
|
|
165
|
-
const { branding, iconSrc } = useBranding();
|
|
187
|
+
const { branding: fetchedBranding, iconSrc: fetchedIconSrc } = useBranding();
|
|
188
|
+
// The server's copy until the fetch answers, so the frame's shape (a custom header replaces the title bar) never changes after the first paint.
|
|
189
|
+
const branding = fetchedBranding ?? initialBranding ?? null;
|
|
190
|
+
const iconSrc = fetchedBranding ? fetchedIconSrc : initialBranding?.iconUrl || initialBranding?.logoUrl || fetchedIconSrc;
|
|
191
|
+
// The custom header and footer, sanitized and with their `{USER_MENU}` / `{APP_TITLE}` placeholders (`undefined` until parsed, `null` when there is none).
|
|
192
|
+
const header = useBrandingHtml(branding?.headerHtml);
|
|
193
|
+
const footer = useBrandingHtml(branding?.footerHtml);
|
|
166
194
|
// The mailboxes, their folders and the one push connection live here, in the frame that stays mounted as the router swaps pages - so
|
|
167
195
|
// new-mail pop-ups, the folder counters and the tab title's unread count work in Calendar, Contacts, Tasks and Settings too, and moving
|
|
168
196
|
// between apps never opens a second socket (the server allows ten per user). `MailShell` reads them from `MailConnectionContext`. Outside
|
|
@@ -170,8 +198,20 @@ export function AppChrome({
|
|
|
170
198
|
const inFrame = useInAppFrame();
|
|
171
199
|
const navigate = useNavigate();
|
|
172
200
|
const mail = useMailConnection({ userUid, enabled: inFrame, open: navigate });
|
|
201
|
+
// A signing certificate the user asked for is announced when it is issued (or fails), on whichever page they are - see the hook.
|
|
202
|
+
useSigningEnrollmentWatcher({ userUid, mailboxes: mail.mailboxes, enabled: inFrame });
|
|
173
203
|
|
|
174
204
|
useRedirectIfUnauthenticated(userUid, authServerUrl);
|
|
205
|
+
// Any request this app makes that the server answers with a 401 - the session ended - raises one "Your session expired" pop-up with a
|
|
206
|
+
// Sign in action (see `notifySessionExpired()`), whichever request noticed first, a background refresh included.
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
setSignInUrl(authServerUrl);
|
|
209
|
+
if (!userUid) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
setApiUnauthorizedObserver(() => void notifySessionExpired());
|
|
213
|
+
return () => setApiUnauthorizedObserver(undefined);
|
|
214
|
+
}, [userUid, authServerUrl]);
|
|
175
215
|
// Mounted here, not scoped to Mail/Settings (the only shells that actually read unlocked keys),
|
|
176
216
|
// specifically so activity in *any* app resets the idle clock - see that hook's own doc comment.
|
|
177
217
|
useIdleKeyTimeout();
|
|
@@ -213,6 +253,7 @@ export function AppChrome({
|
|
|
213
253
|
markSigningOut();
|
|
214
254
|
destroyUnlockedKeys();
|
|
215
255
|
clearPinnedSignerCache();
|
|
256
|
+
clearAppearanceCache();
|
|
216
257
|
// Bounded by its own timeout and never rejects - awaited so navigating doesn't kill the Worker mid-delete.
|
|
217
258
|
void destroyAllLocalIndexes().then(() => {
|
|
218
259
|
window.location.href = authServerUrl ?? "/";
|
|
@@ -230,6 +271,7 @@ export function AppChrome({
|
|
|
230
271
|
destroyUnlockedKeys();
|
|
231
272
|
// Trusted signer pins read from contacts don't outlive the session either.
|
|
232
273
|
clearPinnedSignerCache();
|
|
274
|
+
clearAppearanceCache();
|
|
233
275
|
// Open compose windows save edits still waiting on their autosave debounce while the session is still
|
|
234
276
|
// valid - logout invalidates it. Bounded the same way as logout itself, and never rejects.
|
|
235
277
|
await flushComposeDrafts(LOGOUT_TIMEOUT_MS);
|
|
@@ -290,21 +332,55 @@ export function AppChrome({
|
|
|
290
332
|
const apps = appRailItems(pluginNav);
|
|
291
333
|
// The header title: "settings" has no rail item, everything else is labelled by its own rail item.
|
|
292
334
|
const activeLabel = active === "settings" ? "Settings" : apps.find((app) => app.id === active)?.label;
|
|
335
|
+
// A custom header (`Branding.headerHtml`) replaces the app's own title bar and the icon at the top of the rail: it is the top of the app, and the
|
|
336
|
+
// account menu moves into it. While it is still being parsed it already counts, so the frame's shape doesn't change under the user.
|
|
337
|
+
const customHeader = header !== null;
|
|
338
|
+
// Where the one account menu lives: the header's `{USER_MENU}`, else the footer's, else a small cell at the header's right end - never lost.
|
|
339
|
+
const menuInHeader = !!header?.hasUserMenu;
|
|
340
|
+
const menuInFooter = customHeader && !!header && !menuInHeader && !!footer?.hasUserMenu;
|
|
341
|
+
const renderUserMenu = (placement: "down" | "up") => (
|
|
342
|
+
<UserMenu
|
|
343
|
+
userUid={userUid}
|
|
344
|
+
authServerUrl={authServerUrl}
|
|
345
|
+
onSignOut={handleSignOut}
|
|
346
|
+
// An impersonating administrator acts as the impersonated user, so the console link is hidden then.
|
|
347
|
+
showAdminLink={!!trusted && !impersonating}
|
|
348
|
+
detectAdmin={!trusted && !impersonating}
|
|
349
|
+
trustedRoles={trustedRoles}
|
|
350
|
+
showSettingsLink
|
|
351
|
+
showNotificationSettings
|
|
352
|
+
onShowShortcuts={() => setShortcutsOpen(true)}
|
|
353
|
+
onShowNotifications={() => setHistoryOpen(true)}
|
|
354
|
+
unseenErrors={unseenErrors}
|
|
355
|
+
placement={placement}
|
|
356
|
+
/>
|
|
357
|
+
);
|
|
293
358
|
|
|
294
359
|
return (
|
|
295
360
|
// Mounted here, not scoped to Mail/Settings, for the same reason as useIdleKeyTimeout() above -
|
|
296
361
|
// ComposeWindow's sign/encrypt toggles and MessageDetailPane's encrypted-message view (both Mail)
|
|
297
362
|
// are today's only useUnlockPrompt() callers, but this needs to be available to any app shell.
|
|
363
|
+
// Asks the server for the user's appearance only in the persistent frame (every core page); a page that renders its own chrome (a plugin's) uses the
|
|
364
|
+
// `appearance` it was given and what this browser cached.
|
|
365
|
+
<AppearanceProvider userUid={userUid} initial={appearance} lookUp={inFrame}>
|
|
298
366
|
<ShortcutProvider>
|
|
299
367
|
<MailConnectionContext.Provider value={inFrame ? mail : null}>
|
|
300
368
|
<GlobalShortcuts authServerUrl={authServerUrl} onToggleHelp={() => setShortcutsOpen((open) => !open)} />
|
|
301
369
|
<ShortcutsDialog open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
|
302
370
|
<UnlockPromptProvider>
|
|
371
|
+
<UnlockBridge />
|
|
303
372
|
{/* An impersonating admin acts with the impersonated user's access, so their own trusted role
|
|
304
373
|
mustn't skip the per-mailbox checks. */}
|
|
305
374
|
<ComposeProvider userUid={userUid} trusted={!!trusted && !impersonating}>
|
|
306
|
-
<div className="min-h-screen flex flex-col
|
|
307
|
-
{!hideChrome &&
|
|
375
|
+
<div className="rr-frame-bg min-h-screen flex flex-col">
|
|
376
|
+
{!hideChrome && customHeader && (
|
|
377
|
+
<FrameBrandingHeader
|
|
378
|
+
parsed={header}
|
|
379
|
+
userMenu={menuInHeader ? renderUserMenu("down") : undefined}
|
|
380
|
+
fallbackMenu={header && !menuInHeader && !menuInFooter ? renderUserMenu("down") : undefined}
|
|
381
|
+
appTitle={activeLabel}
|
|
382
|
+
/>
|
|
383
|
+
)}
|
|
308
384
|
{impersonating && !hideChrome && (
|
|
309
385
|
<div className="h-10 shrink-0 bg-warning text-warning-contrast flex items-center justify-center gap-3 text-sm font-medium px-4">
|
|
310
386
|
<span>
|
|
@@ -324,9 +400,15 @@ export function AppChrome({
|
|
|
324
400
|
{!hideChrome && (
|
|
325
401
|
<nav
|
|
326
402
|
aria-label="Apps"
|
|
327
|
-
className=
|
|
403
|
+
className={[
|
|
404
|
+
"hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center gap-1",
|
|
405
|
+
// Under a custom header the rail starts with the app icons; without one its own icon is flush with the top of the window.
|
|
406
|
+
customHeader ? "py-3" : "pb-3",
|
|
407
|
+
].join(" ")}
|
|
328
408
|
>
|
|
329
|
-
|
|
409
|
+
{!customHeader && (
|
|
410
|
+
<RailIcon src={iconSrc} />
|
|
411
|
+
)}
|
|
330
412
|
{apps.map(({ id, href, label, icon: Icon }) => (
|
|
331
413
|
<a
|
|
332
414
|
key={id}
|
|
@@ -348,23 +430,14 @@ export function AppChrome({
|
|
|
348
430
|
)}
|
|
349
431
|
{!hideChrome && <BottomTabBar apps={apps} active={active} />}
|
|
350
432
|
<div className="flex-1 flex flex-col min-w-0">
|
|
351
|
-
{!hideChrome && (
|
|
352
|
-
<header className="h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6">
|
|
433
|
+
{!hideChrome && !customHeader && (
|
|
434
|
+
<header ref={headerRef} className="rr-solid sticky top-0 z-30 h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6">
|
|
353
435
|
<span className="font-display font-bold text-lg uppercase tracking-wide">{activeLabel}</span>
|
|
354
|
-
|
|
355
|
-
userUid={userUid}
|
|
356
|
-
authServerUrl={authServerUrl}
|
|
357
|
-
onSignOut={handleSignOut}
|
|
358
|
-
// An impersonating administrator acts as the impersonated user, so the console link is hidden then.
|
|
359
|
-
showAdminLink={!!trusted && !impersonating}
|
|
360
|
-
detectAdmin={!trusted && !impersonating}
|
|
361
|
-
trustedRoles={trustedRoles}
|
|
362
|
-
showSettingsLink
|
|
363
|
-
showNotificationSettings
|
|
364
|
-
onShowShortcuts={() => setShortcutsOpen(true)}
|
|
365
|
-
/>
|
|
436
|
+
{renderUserMenu("down")}
|
|
366
437
|
</header>
|
|
367
438
|
)}
|
|
439
|
+
{/* The one pop-up stack for the whole app: right under the header row, so it never covers the account menu. */}
|
|
440
|
+
<NotificationCenter />
|
|
368
441
|
<div
|
|
369
442
|
ref={contentRef}
|
|
370
443
|
id="app-content"
|
|
@@ -381,21 +454,14 @@ export function AppChrome({
|
|
|
381
454
|
{announcement}
|
|
382
455
|
</div>
|
|
383
456
|
)}
|
|
384
|
-
{!hideChrome && <
|
|
457
|
+
{!hideChrome && <FrameBrandingFooter parsed={footer} userMenu={menuInFooter ? renderUserMenu("up") : undefined} appTitle={activeLabel} />}
|
|
385
458
|
</div>
|
|
386
459
|
</ComposeProvider>
|
|
387
460
|
</UnlockPromptProvider>
|
|
388
|
-
{
|
|
389
|
-
<NewMailToasts
|
|
390
|
-
toasts={mail.notifications.toasts}
|
|
391
|
-
onDismiss={mail.notifications.dismiss}
|
|
392
|
-
offerDesktop={mail.notifications.offerDesktop}
|
|
393
|
-
onEnableDesktop={() => void mail.notifications.enableDesktop()}
|
|
394
|
-
onDeclineDesktop={mail.notifications.declineDesktop}
|
|
395
|
-
/>
|
|
396
|
-
)}
|
|
461
|
+
<NotificationHistoryDialog open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
|
397
462
|
</MailConnectionContext.Provider>
|
|
398
463
|
</ShortcutProvider>
|
|
464
|
+
</AppearanceProvider>
|
|
399
465
|
);
|
|
400
466
|
}
|
|
401
467
|
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import React from "react";
|
|
5
|
+
import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
6
|
+
import { createPortal } from "react-dom";
|
|
6
7
|
import DOMPurify from "dompurify";
|
|
7
|
-
import { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
|
|
8
|
+
import type { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
|
|
9
|
+
import { useHeaderHeightRef } from "../../notifications/headerOffset.js";
|
|
8
10
|
|
|
9
11
|
/** Elements that could run script, restyle the whole page, collect input, or embed another page - stripped on
|
|
10
12
|
* top of DOMPurify's defaults (which already drop `<script>`, `on*` handlers, and `javascript:` URLs). */
|
|
@@ -30,6 +32,19 @@ const FORBIDDEN_TAGS = [
|
|
|
30
32
|
];
|
|
31
33
|
const FORBIDDEN_ATTRS = ["action", "formaction", "srcdoc"];
|
|
32
34
|
|
|
35
|
+
/** The attribute that marks the elements React fills in (`{USER_MENU}`, `{APP_TITLE}`). Stripped from the author's own HTML, so only
|
|
36
|
+
* this module's placeholders can carry it. */
|
|
37
|
+
export const SLOT_ATTRIBUTE = "data-rr-slot";
|
|
38
|
+
|
|
39
|
+
/** The variables a branding header or footer may contain, written in the HTML's text. */
|
|
40
|
+
export const USER_MENU_VARIABLE = "{USER_MENU}";
|
|
41
|
+
export const APP_TITLE_VARIABLE = "{APP_TITLE}";
|
|
42
|
+
|
|
43
|
+
const VARIABLE = /\{(USER_MENU|APP_TITLE)\}/g;
|
|
44
|
+
|
|
45
|
+
/** Elements whose text is not markup a placeholder element could live in (raw text and RCDATA), and the ones a menu must not sit inside. */
|
|
46
|
+
const RAW_TEXT_PARENTS = new Set(["TITLE", "TEXTAREA", "OPTION", "STYLE", "SCRIPT", "NOSCRIPT", "XMP", "IFRAME", "NOEMBED", "NOFRAMES", "PLAINTEXT"]);
|
|
47
|
+
|
|
33
48
|
/**
|
|
34
49
|
* Sanitizes admin-configured branding HTML before it's rendered. The server sanitizes on save too; this also
|
|
35
50
|
* covers anything stored before that. Returns `""` where there's no DOM to sanitize with (server-side
|
|
@@ -42,23 +57,195 @@ export function sanitizeBrandingHtml(html: string): string {
|
|
|
42
57
|
return DOMPurify.sanitize(html, { FORBID_TAGS: FORBIDDEN_TAGS, FORBID_ATTR: FORBIDDEN_ATTRS });
|
|
43
58
|
}
|
|
44
59
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
60
|
+
/** Branding HTML ready to render: sanitized, with its variables replaced by placeholder elements for React to fill. */
|
|
61
|
+
export interface ParsedBrandingHtml {
|
|
62
|
+
html: string;
|
|
63
|
+
/** It contains a `{USER_MENU}` where the account menu can be rendered. */
|
|
64
|
+
hasUserMenu: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function slotElement(name: "user-menu" | "app-title"): HTMLSpanElement {
|
|
68
|
+
const span = document.createElement("span");
|
|
69
|
+
span.setAttribute(SLOT_ATTRIBUTE, name);
|
|
70
|
+
return span;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Sanitizes `html` (the same rules as `sanitizeBrandingHtml()`) and swaps the variables in its **text** for placeholder elements:
|
|
75
|
+
* `{USER_MENU}` becomes the slot the account menu is rendered into, `{APP_TITLE}` a slot holding the current app's name.
|
|
76
|
+
*
|
|
77
|
+
* - Only text is searched, after sanitizing - a variable in an attribute (`title="{USER_MENU}"`), a comment, or a `<script>` (which
|
|
78
|
+
* is gone) is never touched, and the author cannot write a placeholder themselves (`data-rr-slot` is stripped).
|
|
79
|
+
* - `{USER_MENU}` is replaced **once**, at its first place in the document; a second (or third) one is removed, so there is never
|
|
80
|
+
* more than one account menu (two would give two sets of duplicate ids and focus targets). One inside a link, or in text that
|
|
81
|
+
* can't hold an element (`<title>`, `<textarea>`), is removed too: a menu inside an `<a>` would navigate when clicked.
|
|
82
|
+
* - `{APP_TITLE}` is replaced everywhere it appears. Inside `<title>`-like raw text it stays as written.
|
|
83
|
+
* - Nothing is matched loosely: the names are upper-case, in braces, with no spaces.
|
|
84
|
+
*
|
|
85
|
+
* `null` when there is nothing to show (no DOM to sanitize with - server-side rendering - or the HTML is empty once sanitized).
|
|
86
|
+
*/
|
|
87
|
+
export function parseBrandingHtml(html: string): ParsedBrandingHtml | null {
|
|
88
|
+
if (!DOMPurify.isSupported) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const fragment = DOMPurify.sanitize(html, {
|
|
92
|
+
FORBID_TAGS: FORBIDDEN_TAGS,
|
|
93
|
+
FORBID_ATTR: [...FORBIDDEN_ATTRS, SLOT_ATTRIBUTE],
|
|
94
|
+
RETURN_DOM_FRAGMENT: true,
|
|
95
|
+
});
|
|
96
|
+
const walker = document.createTreeWalker(fragment, NodeFilter.SHOW_TEXT);
|
|
97
|
+
const texts: Text[] = [];
|
|
98
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
99
|
+
texts.push(node as Text);
|
|
100
|
+
}
|
|
101
|
+
let hasUserMenu = false;
|
|
102
|
+
for (const text of texts) {
|
|
103
|
+
const parent = text.parentElement;
|
|
104
|
+
if (parent && RAW_TEXT_PARENTS.has(parent.tagName)) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const inLink = !!parent?.closest("a");
|
|
108
|
+
// `split` with a capturing group alternates text and variable names: [text, name, text, name, ..., text].
|
|
109
|
+
const pieces = text.data.split(VARIABLE);
|
|
110
|
+
if (pieces.length === 1) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const replacement = document.createDocumentFragment();
|
|
114
|
+
pieces.forEach((piece, index) => {
|
|
115
|
+
if (index % 2 === 0) {
|
|
116
|
+
if (piece) {
|
|
117
|
+
replacement.appendChild(document.createTextNode(piece));
|
|
118
|
+
}
|
|
119
|
+
} else if (piece === "APP_TITLE") {
|
|
120
|
+
replacement.appendChild(slotElement("app-title"));
|
|
121
|
+
} else if (!hasUserMenu && !inLink) {
|
|
122
|
+
hasUserMenu = true;
|
|
123
|
+
replacement.appendChild(slotElement("user-menu"));
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
text.replaceWith(replacement);
|
|
127
|
+
}
|
|
128
|
+
const holder = document.createElement("div");
|
|
129
|
+
holder.appendChild(fragment);
|
|
130
|
+
const result = holder.innerHTML;
|
|
131
|
+
return result.trim() ? { html: result, hasUserMenu } : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* `parseBrandingHtml()` of `html`, done once the component is on the page (never while rendering: the server has no DOM to sanitize
|
|
138
|
+
* with, and rendering different HTML there than the hydrating client would leave the page without it). `null` when there is no HTML,
|
|
139
|
+
* or nothing is left of it; `undefined` while it has not been parsed yet - the frame reserves the header's place meanwhile.
|
|
140
|
+
*/
|
|
141
|
+
export function useBrandingHtml(html: string | undefined): ParsedBrandingHtml | null | undefined {
|
|
142
|
+
const source = html?.trim() ? html : undefined;
|
|
143
|
+
const [parsed, setParsed] = useState<{ source: string; value: ParsedBrandingHtml | null } | undefined>(undefined);
|
|
144
|
+
useIsomorphicLayoutEffect(() => {
|
|
145
|
+
setParsed(source === undefined ? undefined : { source, value: parseBrandingHtml(source) });
|
|
146
|
+
}, [source]);
|
|
147
|
+
if (source === undefined) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
return parsed?.source === source ? parsed.value : undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
interface BrandingHtmlProps {
|
|
154
|
+
parsed: ParsedBrandingHtml | null | undefined;
|
|
155
|
+
/** Rendered into the `{USER_MENU}` slot - only where `hostMenu` says this HTML is where the menu lives. */
|
|
156
|
+
userMenu?: ReactNode;
|
|
157
|
+
hostMenu?: boolean;
|
|
158
|
+
/** The current app's name, rendered into every `{APP_TITLE}` slot. */
|
|
159
|
+
appTitle?: string;
|
|
160
|
+
className?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function BrandingHtml({ parsed, userMenu, hostMenu, appTitle, className }: BrandingHtmlProps) {
|
|
164
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
165
|
+
const [targets, setTargets] = useState<{ menu: Element | null; titles: Element[] }>({ menu: null, titles: [] });
|
|
166
|
+
const html = parsed?.html ?? "";
|
|
167
|
+
// The placeholders exist once React has set the HTML: find them here, before the browser paints, and portal into them.
|
|
168
|
+
useIsomorphicLayoutEffect(() => {
|
|
169
|
+
const root = ref.current;
|
|
170
|
+
setTargets({
|
|
171
|
+
menu: root?.querySelector(`[${SLOT_ATTRIBUTE}="user-menu"]`) ?? null,
|
|
172
|
+
titles: root ? Array.from(root.querySelectorAll(`[${SLOT_ATTRIBUTE}="app-title"]`)) : [],
|
|
173
|
+
});
|
|
174
|
+
}, [html]);
|
|
175
|
+
if (parsed === null) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
return (
|
|
179
|
+
<>
|
|
180
|
+
<div ref={ref} className={className} dangerouslySetInnerHTML={{ __html: html }} />
|
|
181
|
+
{hostMenu && targets.menu && createPortal(userMenu, targets.menu)}
|
|
182
|
+
{targets.titles.map((element, index) => createPortal(appTitle, element, `app-title-${index}`))}
|
|
183
|
+
</>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface FrameBrandingHeaderProps {
|
|
188
|
+
/** `useBrandingHtml(branding.headerHtml)`. `null` renders nothing. */
|
|
189
|
+
parsed: ParsedBrandingHtml | null | undefined;
|
|
190
|
+
/** The account menu, rendered into the header's `{USER_MENU}` (when it has one). */
|
|
191
|
+
userMenu?: ReactNode;
|
|
192
|
+
/** Rendered at the right end of the header when it has no `{USER_MENU}` and the footer doesn't host the menu either - the account menu is never lost. */
|
|
193
|
+
fallbackMenu?: ReactNode;
|
|
194
|
+
appTitle?: string;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The frame's version of the admin-configured `Branding.headerHtml` (see `useBranding()`), sanitized, in a `<header>` that stays at the top of the window as the
|
|
199
|
+
* page scrolls. When it is there the app has no title bar of its own (`AppChrome`): the header is the top of the app, and the account
|
|
200
|
+
* menu lives in it - where the HTML writes `{USER_MENU}`, else in a small cell at its right end. The webmail chrome (`AppShell`) shows
|
|
201
|
+
* it; the admin console has no custom header (its header is the console's own) and the escrow console renders no branding HTML at all.
|
|
202
|
+
*/
|
|
203
|
+
export function FrameBrandingHeader({ parsed, userMenu, fallbackMenu, appTitle }: FrameBrandingHeaderProps) {
|
|
204
|
+
// Publishes the header's height (`--rr-header-h`) for the pop-up stack, which sticks just below it.
|
|
205
|
+
const headerRef = useHeaderHeightRef();
|
|
206
|
+
if (parsed === null) {
|
|
48
207
|
return null;
|
|
49
208
|
}
|
|
50
|
-
return
|
|
209
|
+
return (
|
|
210
|
+
<header ref={headerRef} className="sticky top-0 z-30 shrink-0 flex items-stretch bg-surface-alt" data-branding-header="">
|
|
211
|
+
<BrandingHtml parsed={parsed} userMenu={userMenu} hostMenu={parsed?.hasUserMenu} appTitle={appTitle} className="flex-1 min-w-0" />
|
|
212
|
+
{parsed && !parsed.hasUserMenu && fallbackMenu ? (
|
|
213
|
+
<div className="shrink-0 flex items-center px-3 bg-surface border-l border-border" data-branding-menu-cell="">
|
|
214
|
+
{fallbackMenu}
|
|
215
|
+
</div>
|
|
216
|
+
) : null}
|
|
217
|
+
</header>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface FrameBrandingFooterProps {
|
|
222
|
+
/** `useBrandingHtml(branding.footerHtml)`. */
|
|
223
|
+
parsed: ParsedBrandingHtml | null | undefined;
|
|
224
|
+
/** Rendered into the footer's `{USER_MENU}` - passed only when the header has none. */
|
|
225
|
+
userMenu?: ReactNode;
|
|
226
|
+
appTitle?: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The frame's version of the admin-configured `Branding.footerHtml`, sanitized. Its variables work as in the header; a `{USER_MENU}` here only takes the menu when the header has none. */
|
|
230
|
+
export function FrameBrandingFooter({ parsed, userMenu, appTitle }: FrameBrandingFooterProps) {
|
|
231
|
+
return <BrandingHtml parsed={parsed} userMenu={userMenu} hostMenu={userMenu !== undefined} appTitle={appTitle} />;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface BrandingChromeProps {
|
|
235
|
+
branding: Branding | null;
|
|
236
|
+
/** What `{APP_TITLE}` is replaced with - the page's own name. Nothing when absent. */
|
|
237
|
+
appTitle?: string;
|
|
51
238
|
}
|
|
52
239
|
|
|
53
240
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* escrow console deliberately renders no branding HTML at all.
|
|
241
|
+
* The admin-configured `Branding.headerHtml`, sanitized, for pages that don't use a shell, such as public pages (the booking pages). There is no
|
|
242
|
+
* account menu there, so a `{USER_MENU}` in the HTML renders nothing. Renders nothing without branding or HTML.
|
|
57
243
|
*/
|
|
58
|
-
export function BrandingHeader({ branding }:
|
|
59
|
-
return <BrandingHtml
|
|
244
|
+
export function BrandingHeader({ branding, appTitle }: BrandingChromeProps) {
|
|
245
|
+
return <BrandingHtml parsed={useBrandingHtml(branding?.headerHtml)} appTitle={appTitle} />;
|
|
60
246
|
}
|
|
61
247
|
|
|
62
|
-
|
|
63
|
-
|
|
248
|
+
/** The admin-configured `Branding.footerHtml`, sanitized, for pages that don't use a shell - see `BrandingHeader`. */
|
|
249
|
+
export function BrandingFooter({ branding, appTitle }: BrandingChromeProps) {
|
|
250
|
+
return <BrandingHtml parsed={useBrandingHtml(branding?.footerHtml)} appTitle={appTitle} />;
|
|
64
251
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useEffect, useState } from "react";
|
|
6
|
+
|
|
7
|
+
/** The side, in CSS pixels, of the square the icon is fitted into and measured in: the rail is 4rem wide and the title bar 4rem tall, each less its 1px border. */
|
|
8
|
+
const SIZE = 63;
|
|
9
|
+
|
|
10
|
+
/** Pixels this transparent or less count as empty. */
|
|
11
|
+
const EMPTY_ALPHA = 24;
|
|
12
|
+
|
|
13
|
+
/** The number of rows at the top of `size` x `size` RGBA pixel data that are empty - the icon's own margin above its first visible pixel. */
|
|
14
|
+
export function emptyRowsAtTop(rgba: ArrayLike<number>, size: number): number {
|
|
15
|
+
for (let y = 0; y < size; y++) {
|
|
16
|
+
for (let x = 0; x < size; x++) {
|
|
17
|
+
if (rgba[(y * size + x) * 4 + 3] > EMPTY_ALPHA) {
|
|
18
|
+
return y;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Whether this environment can decode an image and read it back - not a server, and not jsdom, where there is nothing to measure. */
|
|
26
|
+
export function canMeasureImages(): boolean {
|
|
27
|
+
try {
|
|
28
|
+
return typeof new Image().decode === "function";
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* How many pixels of empty margin an icon has above its first visible pixel when fitted (`object-fit: contain`) into a `SIZE` square.
|
|
36
|
+
* Drawn on a canvas and read back, so it works for any picture the browser can decode from the same origin (or with CORS); `0` where
|
|
37
|
+
* that isn't possible (no canvas, a picture that won't load, one from another origin that taints the canvas).
|
|
38
|
+
*/
|
|
39
|
+
export async function measureTopMargin(src: string): Promise<number> {
|
|
40
|
+
try {
|
|
41
|
+
const image = new Image();
|
|
42
|
+
image.src = src;
|
|
43
|
+
await image.decode();
|
|
44
|
+
const canvas = document.createElement("canvas");
|
|
45
|
+
canvas.width = SIZE;
|
|
46
|
+
canvas.height = SIZE;
|
|
47
|
+
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
48
|
+
if (!context || !image.naturalWidth || !image.naturalHeight) {
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
const scale = Math.min(SIZE / image.naturalWidth, SIZE / image.naturalHeight);
|
|
52
|
+
const width = image.naturalWidth * scale;
|
|
53
|
+
const height = image.naturalHeight * scale;
|
|
54
|
+
context.drawImage(image, (SIZE - width) / 2, (SIZE - height) / 2, width, height);
|
|
55
|
+
return emptyRowsAtTop(context.getImageData(0, 0, SIZE, SIZE).data, SIZE);
|
|
56
|
+
} catch {
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The deployment's icon at the top of an app rail (`AppShell`, `AdminShell`, `EscrowShell`): a block as wide as the rail and as tall as the
|
|
63
|
+
* title bar beside it (4rem), flush with the top and left edges of the window - no padding above it - with the bar's own bottom border
|
|
64
|
+
* continued under it, so the two read as one block. The picture is fitted inside the block, never cropped, and raised by whatever empty
|
|
65
|
+
* margin the file itself has above its artwork, so the first visible pixel is at the very top of the window (a logo exported with 6% of
|
|
66
|
+
* air around it would otherwise still leave a gap). The icon is shown once measured (a few milliseconds after it loads).
|
|
67
|
+
*/
|
|
68
|
+
export default function RailIcon({ src }: { src: string }) {
|
|
69
|
+
const [margin, setMargin] = useState<number | null>(null);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
let cancelled = false;
|
|
72
|
+
if (!canMeasureImages()) {
|
|
73
|
+
// Nothing to measure with: show the icon as it is (in the same effect pass, so nothing waits and nothing updates later).
|
|
74
|
+
setMargin(0);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
setMargin(null);
|
|
78
|
+
void measureTopMargin(src).then((measured) => {
|
|
79
|
+
if (!cancelled) {
|
|
80
|
+
setMargin(measured);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
return () => {
|
|
84
|
+
cancelled = true;
|
|
85
|
+
};
|
|
86
|
+
}, [src]);
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className="h-16 w-full shrink-0 overflow-hidden border-b border-border mb-2" data-rail-icon="">
|
|
90
|
+
<img
|
|
91
|
+
src={src}
|
|
92
|
+
width="64"
|
|
93
|
+
height="64"
|
|
94
|
+
alt=""
|
|
95
|
+
className="block h-full w-full object-contain"
|
|
96
|
+
style={{ transform: margin ? `translateY(-${margin}px)` : undefined, opacity: margin === null ? 0 : 1 }}
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|