@rapidmx/web-client 0.10.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 +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/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/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/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/PrincipalPicker.tsx +145 -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/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/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/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/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.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
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { KeyboardEvent, ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
6
|
+
import { createPortal } from "react-dom";
|
|
7
|
+
import type { IconType } from "react-icons";
|
|
8
|
+
import { HiOutlineEllipsisHorizontal } from "react-icons/hi2";
|
|
9
|
+
|
|
10
|
+
/** One button of a `ResponsiveToolbar`. */
|
|
11
|
+
export interface ToolbarAction {
|
|
12
|
+
id: string;
|
|
13
|
+
/** The visible caption while there is room, else the tooltip and the accessible name. */
|
|
14
|
+
label: string;
|
|
15
|
+
icon: IconType;
|
|
16
|
+
onClick: () => void;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
/** A toggle: the button's pressed state. */
|
|
19
|
+
pressed?: boolean;
|
|
20
|
+
/** `title` and `aria-keyshortcuts` for a button with a shortcut - kept as given, whatever the layout. */
|
|
21
|
+
hint?: { title: string; "aria-keyshortcuts"?: string };
|
|
22
|
+
/** Actions with the same `group` sit together; a divider goes between groups. */
|
|
23
|
+
group: number;
|
|
24
|
+
/** Which goes into the "More" menu first when there is not room: the lowest number first. Never used for `essential`. */
|
|
25
|
+
rank: number;
|
|
26
|
+
/** Never moves into the "More" menu (the primary action). */
|
|
27
|
+
essential?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Room (px) each kind of button takes, and what the bar spends on its padding, a divider and the "More" button - the layout is computed from them
|
|
31
|
+
* rather than measured, so the first paint on the client is already right and nothing jumps. */
|
|
32
|
+
export const LABELLED_BUTTON_WIDTH = 64;
|
|
33
|
+
export const ICON_BUTTON_WIDTH = 36;
|
|
34
|
+
const BAR_PADDING = 16;
|
|
35
|
+
const DIVIDER_WIDTH = 9;
|
|
36
|
+
|
|
37
|
+
export interface ToolbarLayout {
|
|
38
|
+
/** Captions under the icons. */
|
|
39
|
+
labels: boolean;
|
|
40
|
+
/** The ids of the actions shown as buttons, in order. The rest are in the "More" menu. */
|
|
41
|
+
visible: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function widthOf(actions: ToolbarAction[], perButton: number, more: boolean): number {
|
|
45
|
+
const groups = new Set(actions.map((action) => action.group)).size;
|
|
46
|
+
return BAR_PADDING + actions.length * perButton + Math.max(0, groups - 1) * DIVIDER_WIDTH + (more ? perButton : 0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Which buttons a bar of `width` px shows, and how: every one with its caption when that fits; else every one as an icon (the captions go first);
|
|
51
|
+
* else as many icons as fit, the least important (`rank`) going into a "More" menu one at a time - the essential ones stay whatever the width.
|
|
52
|
+
* Pure, so it is the same on the server and the client for a given width; `width === undefined` (not measured yet, no ResizeObserver) shows
|
|
53
|
+
* everything.
|
|
54
|
+
*/
|
|
55
|
+
export function layoutToolbar(actions: ToolbarAction[], width: number | undefined): ToolbarLayout {
|
|
56
|
+
const ordered = [...actions].sort((a, b) => a.group - b.group);
|
|
57
|
+
const all = ordered.map((action) => action.id);
|
|
58
|
+
if (width === undefined) {
|
|
59
|
+
return { labels: true, visible: all };
|
|
60
|
+
}
|
|
61
|
+
if (widthOf(ordered, LABELLED_BUTTON_WIDTH, false) <= width) {
|
|
62
|
+
return { labels: true, visible: all };
|
|
63
|
+
}
|
|
64
|
+
if (widthOf(ordered, ICON_BUTTON_WIDTH, false) <= width) {
|
|
65
|
+
return { labels: false, visible: all };
|
|
66
|
+
}
|
|
67
|
+
// Not everything fits: the least important actions go into "More" one at a time, until what is left and the "More" button do.
|
|
68
|
+
let shown = ordered;
|
|
69
|
+
for (const dropped of ordered.filter((action) => !action.essential).sort((x, y) => x.rank - y.rank)) {
|
|
70
|
+
if (widthOf(shown, ICON_BUTTON_WIDTH, true) <= width) {
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
shown = shown.filter((action) => action !== dropped);
|
|
74
|
+
}
|
|
75
|
+
return { labels: false, visible: shown.map((action) => action.id) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The width of `ref`'s element, kept current with a ResizeObserver; `undefined` until measured (and where there is no ResizeObserver). */
|
|
79
|
+
export function useElementWidth(ref: React.RefObject<HTMLElement | null>): number | undefined {
|
|
80
|
+
const [width, setWidth] = useState<number | undefined>(undefined);
|
|
81
|
+
useLayoutEffect(() => {
|
|
82
|
+
const element = ref.current;
|
|
83
|
+
if (!element || typeof ResizeObserver === "undefined") {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
setWidth(element.getBoundingClientRect().width);
|
|
87
|
+
const observer = new ResizeObserver((entries) => {
|
|
88
|
+
const box = entries[0]?.contentRect.width;
|
|
89
|
+
if (box !== undefined) {
|
|
90
|
+
setWidth(Math.round(box));
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
observer.observe(element);
|
|
94
|
+
return () => observer.disconnect();
|
|
95
|
+
}, [ref]);
|
|
96
|
+
return width;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function ToolbarButton({ action, labels }: { action: ToolbarAction; labels: boolean }) {
|
|
100
|
+
const Icon = action.icon;
|
|
101
|
+
return (
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
onClick={action.onClick}
|
|
105
|
+
disabled={action.disabled}
|
|
106
|
+
aria-pressed={action.pressed}
|
|
107
|
+
// A tooltip when the caption is not on show (the shortcut's own title, when it has one, wins).
|
|
108
|
+
title={action.hint?.title ?? (labels ? undefined : action.label)}
|
|
109
|
+
aria-keyshortcuts={action.hint?.["aria-keyshortcuts"]}
|
|
110
|
+
data-toolbar-item=""
|
|
111
|
+
className={[
|
|
112
|
+
"flex shrink-0 flex-col items-center justify-center gap-1 rounded-sm text-xs disabled:opacity-40 disabled:cursor-not-allowed",
|
|
113
|
+
labels ? "w-16 py-1.5" : "h-9 w-9",
|
|
114
|
+
action.pressed ? "bg-primary/10 text-primary-dark" : "text-text-muted hover:not-disabled:bg-surface-alt hover:not-disabled:text-text",
|
|
115
|
+
].join(" ")}
|
|
116
|
+
>
|
|
117
|
+
<Icon size={18} aria-hidden="true" />
|
|
118
|
+
<span className={labels ? "leading-tight text-center" : "sr-only"}>{action.label}</span>
|
|
119
|
+
</button>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The menu that holds what does not fit: a button, and a portalled `role="menu"` so nothing clips it. Arrow keys, Home, End and Escape; focus returns to the button. */
|
|
124
|
+
function MoreMenu({ actions }: { actions: ToolbarAction[] }) {
|
|
125
|
+
const [open, setOpen] = useState(false);
|
|
126
|
+
const [anchor, setAnchor] = useState<{ right: number; top: number } | null>(null);
|
|
127
|
+
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
128
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
129
|
+
|
|
130
|
+
useLayoutEffect(() => {
|
|
131
|
+
if (!open) {
|
|
132
|
+
setAnchor(null);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
function place() {
|
|
136
|
+
const rect = buttonRef.current!.getBoundingClientRect();
|
|
137
|
+
setAnchor({ right: Math.max(8, window.innerWidth - rect.right), top: rect.bottom + 4 });
|
|
138
|
+
}
|
|
139
|
+
place();
|
|
140
|
+
window.addEventListener("resize", place);
|
|
141
|
+
window.addEventListener("scroll", place, true);
|
|
142
|
+
return () => {
|
|
143
|
+
window.removeEventListener("resize", place);
|
|
144
|
+
window.removeEventListener("scroll", place, true);
|
|
145
|
+
};
|
|
146
|
+
}, [open]);
|
|
147
|
+
|
|
148
|
+
// On open, the first item that can be used gets the focus.
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (open && anchor) {
|
|
151
|
+
menuRef.current?.querySelector<HTMLElement>('[role="menuitem"]:not(:disabled)')?.focus();
|
|
152
|
+
}
|
|
153
|
+
}, [open, anchor !== null]);
|
|
154
|
+
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (!open) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
function handlePointerDown(event: MouseEvent) {
|
|
160
|
+
if (event.target instanceof Node && !buttonRef.current?.contains(event.target) && !menuRef.current?.contains(event.target)) {
|
|
161
|
+
setOpen(false);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
document.addEventListener("mousedown", handlePointerDown);
|
|
165
|
+
return () => document.removeEventListener("mousedown", handlePointerDown);
|
|
166
|
+
}, [open]);
|
|
167
|
+
|
|
168
|
+
function close(refocus: boolean) {
|
|
169
|
+
setOpen(false);
|
|
170
|
+
if (refocus) {
|
|
171
|
+
buttonRef.current?.focus();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function handleMenuKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
|
176
|
+
const items = Array.from(menuRef.current!.querySelectorAll<HTMLElement>('[role="menuitem"]:not(:disabled)'));
|
|
177
|
+
const index = items.indexOf(document.activeElement as HTMLElement);
|
|
178
|
+
const focusAt = (next: number) => {
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
items[(next + items.length) % items.length]?.focus();
|
|
181
|
+
};
|
|
182
|
+
if (event.key === "Escape") {
|
|
183
|
+
event.preventDefault();
|
|
184
|
+
event.stopPropagation();
|
|
185
|
+
close(true);
|
|
186
|
+
} else if (event.key === "ArrowDown") {
|
|
187
|
+
focusAt(index + 1);
|
|
188
|
+
} else if (event.key === "ArrowUp") {
|
|
189
|
+
focusAt(index - 1);
|
|
190
|
+
} else if (event.key === "Home") {
|
|
191
|
+
focusAt(0);
|
|
192
|
+
} else if (event.key === "End") {
|
|
193
|
+
focusAt(items.length - 1);
|
|
194
|
+
} else if (event.key === "Tab") {
|
|
195
|
+
close(false);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<>
|
|
201
|
+
<button
|
|
202
|
+
ref={buttonRef}
|
|
203
|
+
type="button"
|
|
204
|
+
aria-haspopup="menu"
|
|
205
|
+
aria-expanded={open}
|
|
206
|
+
aria-label="More actions"
|
|
207
|
+
title="More actions"
|
|
208
|
+
data-toolbar-item=""
|
|
209
|
+
onClick={() => setOpen((current) => !current)}
|
|
210
|
+
onKeyDown={(event) => {
|
|
211
|
+
if (event.key === "ArrowDown" && !open) {
|
|
212
|
+
event.preventDefault();
|
|
213
|
+
setOpen(true);
|
|
214
|
+
}
|
|
215
|
+
}}
|
|
216
|
+
className={[
|
|
217
|
+
"flex h-9 w-9 shrink-0 items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text",
|
|
218
|
+
open ? "bg-surface-alt text-text" : "",
|
|
219
|
+
].join(" ")}
|
|
220
|
+
>
|
|
221
|
+
<HiOutlineEllipsisHorizontal size={18} aria-hidden="true" />
|
|
222
|
+
</button>
|
|
223
|
+
{open &&
|
|
224
|
+
anchor &&
|
|
225
|
+
createPortal(
|
|
226
|
+
<div
|
|
227
|
+
ref={menuRef}
|
|
228
|
+
role="menu"
|
|
229
|
+
aria-label="More actions"
|
|
230
|
+
onKeyDown={handleMenuKeyDown}
|
|
231
|
+
style={{ position: "fixed", right: anchor.right, top: anchor.top }}
|
|
232
|
+
className="z-[70] w-52 max-w-[calc(100vw-1rem)] rounded-md border border-border bg-surface py-1.5 shadow-modal"
|
|
233
|
+
>
|
|
234
|
+
{actions.map((action) => {
|
|
235
|
+
const Icon = action.icon;
|
|
236
|
+
return (
|
|
237
|
+
<button
|
|
238
|
+
key={action.id}
|
|
239
|
+
type="button"
|
|
240
|
+
role="menuitem"
|
|
241
|
+
disabled={action.disabled}
|
|
242
|
+
title={action.hint?.title}
|
|
243
|
+
aria-keyshortcuts={action.hint?.["aria-keyshortcuts"]}
|
|
244
|
+
onClick={() => {
|
|
245
|
+
close(true);
|
|
246
|
+
action.onClick();
|
|
247
|
+
}}
|
|
248
|
+
className="flex w-full items-center gap-3 px-3.5 py-2 text-left text-sm text-text hover:not-disabled:bg-surface-alt focus-visible:bg-surface-alt disabled:cursor-not-allowed disabled:opacity-40"
|
|
249
|
+
>
|
|
250
|
+
<Icon size={18} aria-hidden="true" className="shrink-0 text-text-muted" />
|
|
251
|
+
{action.label}
|
|
252
|
+
</button>
|
|
253
|
+
);
|
|
254
|
+
})}
|
|
255
|
+
</div>,
|
|
256
|
+
document.body,
|
|
257
|
+
)}
|
|
258
|
+
</>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* A toolbar that fits the column it is in - never wider, never over another pane. It measures its own width (`ResizeObserver`) and shows every
|
|
264
|
+
* action with a caption when there is room; when there is not, the captions go first (each button keeps its tooltip and accessible name), then the
|
|
265
|
+
* least important actions move into a "More" menu (`layoutToolbar()`). Arrow keys, Home and End move between the buttons; the menu is a real
|
|
266
|
+
* `role="menu"`. `children` (a hidden file input, say) are rendered inside the toolbar's element but take no room.
|
|
267
|
+
*/
|
|
268
|
+
export default function ResponsiveToolbar({ label, actions, leading, children }: { label: string; actions: ToolbarAction[]; leading?: ReactNode; children?: ReactNode }) {
|
|
269
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
270
|
+
const width = useElementWidth(ref);
|
|
271
|
+
const layout = layoutToolbar(actions, width);
|
|
272
|
+
const ordered = [...actions].sort((a, b) => a.group - b.group);
|
|
273
|
+
const shown = ordered.filter((action) => layout.visible.includes(action.id));
|
|
274
|
+
const hidden = ordered.filter((action) => !layout.visible.includes(action.id));
|
|
275
|
+
|
|
276
|
+
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
|
277
|
+
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key) || (event.target as HTMLElement).closest('[role="menu"]')) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const items = Array.from(ref.current!.querySelectorAll<HTMLElement>("[data-toolbar-item]:not(:disabled)"));
|
|
281
|
+
const index = items.indexOf(event.target as HTMLElement);
|
|
282
|
+
if (index === -1) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
event.preventDefault();
|
|
286
|
+
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : index + (event.key === "ArrowRight" ? 1 : -1);
|
|
287
|
+
items[(next + items.length) % items.length].focus();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return (
|
|
291
|
+
<div
|
|
292
|
+
ref={ref}
|
|
293
|
+
role="toolbar"
|
|
294
|
+
aria-label={label}
|
|
295
|
+
onKeyDown={handleKeyDown}
|
|
296
|
+
// `overflow-x-clip` is the guarantee: whatever the layout says, the bar can never paint outside its own column.
|
|
297
|
+
className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip border-b border-border bg-surface-alt px-2 py-1"
|
|
298
|
+
>
|
|
299
|
+
{leading}
|
|
300
|
+
{shown.map((action, index) => (
|
|
301
|
+
<React.Fragment key={action.id}>
|
|
302
|
+
{index > 0 && shown[index - 1].group !== action.group && <div className="my-1 w-px shrink-0 self-stretch bg-border" aria-hidden="true" />}
|
|
303
|
+
<ToolbarButton action={action} labels={layout.labels} />
|
|
304
|
+
</React.Fragment>
|
|
305
|
+
))}
|
|
306
|
+
{hidden.length > 0 && (
|
|
307
|
+
<>
|
|
308
|
+
<div className="my-1 w-px shrink-0 self-stretch bg-border" aria-hidden="true" />
|
|
309
|
+
<MoreMenu actions={hidden} />
|
|
310
|
+
</>
|
|
311
|
+
)}
|
|
312
|
+
{children}
|
|
313
|
+
</div>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useContext, useId, useRef } from "react";
|
|
6
|
+
import { HiOutlineComputerDesktop, HiOutlineMoon, HiOutlineSun } from "react-icons/hi2";
|
|
7
|
+
import type { AppearanceMode } from "@rapidmx/react-shared/appearance/preferencesApi.js";
|
|
8
|
+
import { AppearanceContext, INERT_APPEARANCE } from "../../appearance/appearanceContext.js";
|
|
9
|
+
|
|
10
|
+
const CHOICES: { mode: AppearanceMode; label: string; hint: string; Icon: typeof HiOutlineSun }[] = [
|
|
11
|
+
{ mode: "system", label: "System", hint: "System - follow this device", Icon: HiOutlineComputerDesktop },
|
|
12
|
+
{ mode: "light", label: "Light", hint: "Light", Icon: HiOutlineSun },
|
|
13
|
+
{ mode: "dark", label: "Dark", hint: "Dark", Icon: HiOutlineMoon },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The account menu's colour scheme control: a row "Theme" with a three-way segmented control of icon buttons - System, Light, Dark - a
|
|
18
|
+
* `radiogroup` (one tab stop, the arrow keys move and choose, Home/End jump). A choice applies through `useAppearance().setPrefs()`, which
|
|
19
|
+
* changes every open surface in the same frame (the app frame, message cards, the consoles) and saves in the background - and puts the change
|
|
20
|
+
* back, with a notice, if the save fails. Renders nothing where there is no `AppearanceProvider` (a shell that has none, a component tested
|
|
21
|
+
* alone): the hook is inert there and a control that changes nothing would be a lie.
|
|
22
|
+
*/
|
|
23
|
+
export default function ThemeSwitch() {
|
|
24
|
+
const appearance = useContext(AppearanceContext);
|
|
25
|
+
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
|
|
26
|
+
const labelId = useId();
|
|
27
|
+
if (appearance === INERT_APPEARANCE) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const current = appearance.prefs.mode;
|
|
31
|
+
|
|
32
|
+
function choose(index: number, focus: boolean) {
|
|
33
|
+
const next = CHOICES[(index + CHOICES.length) % CHOICES.length];
|
|
34
|
+
if (next.mode !== current) {
|
|
35
|
+
appearance.setPrefs({ mode: next.mode });
|
|
36
|
+
}
|
|
37
|
+
if (focus) {
|
|
38
|
+
buttons.current[(index + CHOICES.length) % CHOICES.length]?.focus();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function handleKeyDown(event: React.KeyboardEvent, index: number) {
|
|
43
|
+
const target = { ArrowRight: index + 1, ArrowDown: index + 1, ArrowLeft: index - 1, ArrowUp: index - 1, Home: 0, End: CHOICES.length - 1 }[event.key];
|
|
44
|
+
if (target !== undefined) {
|
|
45
|
+
event.preventDefault();
|
|
46
|
+
choose(target, true);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="flex items-center justify-between gap-3 px-3.5 py-2 border-b border-border">
|
|
52
|
+
<span id={labelId} className="text-sm text-text">
|
|
53
|
+
Theme
|
|
54
|
+
</span>
|
|
55
|
+
<div role="radiogroup" aria-labelledby={labelId} className="inline-flex rounded-sm border border-border bg-surface-alt p-0.5">
|
|
56
|
+
{CHOICES.map(({ mode, label, hint, Icon }, index) => {
|
|
57
|
+
const selected = mode === current;
|
|
58
|
+
return (
|
|
59
|
+
<button
|
|
60
|
+
key={mode}
|
|
61
|
+
ref={(element) => {
|
|
62
|
+
buttons.current[index] = element;
|
|
63
|
+
}}
|
|
64
|
+
type="button"
|
|
65
|
+
role="radio"
|
|
66
|
+
aria-checked={selected}
|
|
67
|
+
aria-label={label}
|
|
68
|
+
title={hint}
|
|
69
|
+
tabIndex={selected ? 0 : -1}
|
|
70
|
+
onClick={() => choose(index, false)}
|
|
71
|
+
onKeyDown={(event) => handleKeyDown(event, index)}
|
|
72
|
+
className={[
|
|
73
|
+
"flex h-7 w-8 items-center justify-center rounded-[3px] focus-visible:outline-2 focus-visible:outline-primary",
|
|
74
|
+
selected ? "bg-surface text-primary-dark shadow-sm" : "text-text-muted hover:text-text",
|
|
75
|
+
].join(" ")}
|
|
76
|
+
>
|
|
77
|
+
<Icon size={16} aria-hidden="true" />
|
|
78
|
+
</button>
|
|
79
|
+
);
|
|
80
|
+
})}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import React, { useEffect, useRef, useState } from "react";
|
|
5
|
+
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
6
|
+
import { createPortal } from "react-dom";
|
|
6
7
|
import { HiOutlineShieldCheck } from "react-icons/hi2";
|
|
7
8
|
import { formatProfileName, getMyProfile, getMyUsername, Profile, profileInitials } from "@rapidmx/react-shared/auth/profileApi.js";
|
|
8
9
|
import { accountUrlOf } from "../../auth/accountUrl.js";
|
|
@@ -11,6 +12,7 @@ import { ariaKeyShortcuts } from "../../keyboard/format.js";
|
|
|
11
12
|
import { SHORTCUTS } from "../../keyboard/keymap.js";
|
|
12
13
|
import { useKeyEnvironment } from "../../keyboard/ShortcutProvider.js";
|
|
13
14
|
import { SETTINGS_HREF } from "../../navigation/appHrefs.js";
|
|
15
|
+
import ThemeSwitch from "./ThemeSwitch.js";
|
|
14
16
|
import {
|
|
15
17
|
DesktopPermission,
|
|
16
18
|
desktopPermission,
|
|
@@ -49,6 +51,12 @@ export interface UserMenuProps {
|
|
|
49
51
|
/** Shows a "Keyboard shortcuts" item that calls this - opening the help dialog (`?` and Ctrl+/ do too). For the shells that have the
|
|
50
52
|
* keyboard layer (`AppChrome`); the consoles don't offer it. */
|
|
51
53
|
onShowShortcuts?: () => void;
|
|
54
|
+
/** Which way the menu opens from its button: `"down"` (the default; in a header) or `"up"` (in a footer, where there is no room below). */
|
|
55
|
+
placement?: "down" | "up";
|
|
56
|
+
/** Shows a "Recent notifications" item that calls this - opening the history of the last pop-ups (`AppChrome` only). */
|
|
57
|
+
onShowNotifications?: () => void;
|
|
58
|
+
/** Errors in that history nobody has looked at yet: a count beside the item, and a dot on the menu button. */
|
|
59
|
+
unseenErrors?: number;
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
function Avatar({ profile, initials, large }: { profile?: Profile; initials: string; large?: boolean }) {
|
|
@@ -91,9 +99,16 @@ export default function UserMenu({
|
|
|
91
99
|
showSettingsLink,
|
|
92
100
|
showNotificationSettings,
|
|
93
101
|
onShowShortcuts,
|
|
102
|
+
placement = "down",
|
|
103
|
+
onShowNotifications,
|
|
104
|
+
unseenErrors = 0,
|
|
94
105
|
}: UserMenuProps) {
|
|
95
106
|
const env = useKeyEnvironment();
|
|
96
107
|
const [open, setOpen] = useState(false);
|
|
108
|
+
// Where the menu sits, in window coordinates: it is drawn through a portal into `<body>` with `position: fixed`, so no ancestor's
|
|
109
|
+
// `overflow`, `z-index` or stacking context - a custom branding header's, say - can clip it or put it behind the page.
|
|
110
|
+
const [anchor, setAnchor] = useState<{ right: number; top?: number; bottom?: number } | null>(null);
|
|
111
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
97
112
|
const [detectedAdmin, setDetectedAdmin] = useState(false);
|
|
98
113
|
const [popups, setPopups] = useState(true);
|
|
99
114
|
const [permission, setPermission] = useState<DesktopPermission>("unsupported");
|
|
@@ -172,7 +187,13 @@ export default function UserMenu({
|
|
|
172
187
|
return;
|
|
173
188
|
}
|
|
174
189
|
function handlePointerDown(e: MouseEvent) {
|
|
175
|
-
|
|
190
|
+
// The menu is portalled out of the container, so it and the button are checked separately.
|
|
191
|
+
if (
|
|
192
|
+
containerRef.current &&
|
|
193
|
+
e.target instanceof Node &&
|
|
194
|
+
!containerRef.current.contains(e.target) &&
|
|
195
|
+
!menuRef.current?.contains(e.target)
|
|
196
|
+
) {
|
|
176
197
|
setOpen(false);
|
|
177
198
|
}
|
|
178
199
|
}
|
|
@@ -189,6 +210,26 @@ export default function UserMenu({
|
|
|
189
210
|
};
|
|
190
211
|
}, [open]);
|
|
191
212
|
|
|
213
|
+
// Measured before paint, and again whenever the window resizes or anything scrolls (a footer's button moves with the page).
|
|
214
|
+
useLayoutEffect(() => {
|
|
215
|
+
if (!open) {
|
|
216
|
+
setAnchor(null);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
function place() {
|
|
220
|
+
const rect = containerRef.current!.getBoundingClientRect();
|
|
221
|
+
const right = Math.max(8, window.innerWidth - rect.right);
|
|
222
|
+
setAnchor(placement === "up" ? { right, bottom: window.innerHeight - rect.top + 8 } : { right, top: rect.bottom + 8 });
|
|
223
|
+
}
|
|
224
|
+
place();
|
|
225
|
+
window.addEventListener("resize", place);
|
|
226
|
+
window.addEventListener("scroll", place, true);
|
|
227
|
+
return () => {
|
|
228
|
+
window.removeEventListener("resize", place);
|
|
229
|
+
window.removeEventListener("scroll", place, true);
|
|
230
|
+
};
|
|
231
|
+
}, [open, placement]);
|
|
232
|
+
|
|
192
233
|
const name = formatProfileName(profile) ?? username ?? userUid;
|
|
193
234
|
const initials = profileInitials(profile, userUid, username);
|
|
194
235
|
const accountUrl = accountUrlOf(authServerUrl);
|
|
@@ -201,9 +242,12 @@ export default function UserMenu({
|
|
|
201
242
|
aria-haspopup="true"
|
|
202
243
|
aria-expanded={open}
|
|
203
244
|
aria-label="Account menu"
|
|
204
|
-
className="flex items-center gap-1.5 rounded-pill py-1 pl-1 pr-2 hover:bg-surface-alt"
|
|
245
|
+
className="relative flex items-center gap-1.5 rounded-pill py-1 pl-1 pr-2 hover:bg-surface-alt"
|
|
205
246
|
>
|
|
206
247
|
<Avatar profile={profile} initials={initials} />
|
|
248
|
+
{unseenErrors > 0 && (
|
|
249
|
+
<span data-testid="unseen-errors-dot" aria-hidden="true" className="absolute right-0.5 top-0.5 h-2.5 w-2.5 rounded-full border-2 border-surface bg-danger" />
|
|
250
|
+
)}
|
|
207
251
|
<svg
|
|
208
252
|
width="14"
|
|
209
253
|
height="14"
|
|
@@ -215,15 +259,20 @@ export default function UserMenu({
|
|
|
215
259
|
<path d="M5 7.5L10 12.5L15 7.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
216
260
|
</svg>
|
|
217
261
|
</button>
|
|
218
|
-
{open &&
|
|
262
|
+
{open &&
|
|
263
|
+
anchor &&
|
|
264
|
+
createPortal(
|
|
219
265
|
<div
|
|
266
|
+
ref={menuRef}
|
|
220
267
|
role="menu"
|
|
221
|
-
|
|
268
|
+
style={{ position: "fixed", right: anchor.right, top: anchor.top, bottom: anchor.bottom }}
|
|
269
|
+
className="w-60 max-w-[calc(100vw-1rem)] max-h-[calc(100vh-5rem)] overflow-y-auto bg-surface border border-border rounded-md shadow-modal py-1.5 z-[70]"
|
|
222
270
|
>
|
|
223
271
|
<div className="flex items-center gap-3 px-3.5 py-2.5 border-b border-border">
|
|
224
272
|
<Avatar profile={profile} initials={initials} large />
|
|
225
273
|
<span className="text-sm font-semibold text-text truncate">{name}</span>
|
|
226
274
|
</div>
|
|
275
|
+
<ThemeSwitch />
|
|
227
276
|
{accountUrl && (
|
|
228
277
|
<a
|
|
229
278
|
role="menuitem"
|
|
@@ -264,6 +313,25 @@ export default function UserMenu({
|
|
|
264
313
|
Turn on desktop notifications
|
|
265
314
|
</button>
|
|
266
315
|
)}
|
|
316
|
+
{onShowNotifications && (
|
|
317
|
+
<button
|
|
318
|
+
role="menuitem"
|
|
319
|
+
type="button"
|
|
320
|
+
onClick={() => {
|
|
321
|
+
setOpen(false);
|
|
322
|
+
onShowNotifications();
|
|
323
|
+
}}
|
|
324
|
+
className="flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt"
|
|
325
|
+
>
|
|
326
|
+
<span>Recent notifications</span>
|
|
327
|
+
{unseenErrors > 0 && (
|
|
328
|
+
<span className="rounded-pill bg-danger px-1.5 text-xs font-bold text-white">
|
|
329
|
+
<span aria-hidden="true">{unseenErrors}</span>
|
|
330
|
+
<span className="sr-only">{unseenErrors} unseen {unseenErrors === 1 ? "error" : "errors"}</span>
|
|
331
|
+
</span>
|
|
332
|
+
)}
|
|
333
|
+
</button>
|
|
334
|
+
)}
|
|
267
335
|
{onShowShortcuts && (
|
|
268
336
|
<button
|
|
269
337
|
role="menuitem"
|
|
@@ -296,7 +364,8 @@ export default function UserMenu({
|
|
|
296
364
|
>
|
|
297
365
|
Sign Out
|
|
298
366
|
</button>
|
|
299
|
-
</div
|
|
367
|
+
</div>,
|
|
368
|
+
document.body,
|
|
300
369
|
)}
|
|
301
370
|
</div>
|
|
302
371
|
);
|
|
@@ -8,6 +8,7 @@ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
|
8
8
|
import { Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
9
9
|
import { formatMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
|
|
10
10
|
import MailAddress from "./MailAddress.js";
|
|
11
|
+
import { EncryptedPreview, conversationLooksEncrypted } from "./reading/EncryptedPreview.js";
|
|
11
12
|
import { ConversationSummary, listConversationMessages } from "@rapidmx/react-shared/mail/conversationsApi.js";
|
|
12
13
|
import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "./unreadStyle.js";
|
|
13
14
|
|
|
@@ -188,7 +189,8 @@ export default function ConversationList({
|
|
|
188
189
|
</div>
|
|
189
190
|
<div className={["text-sm truncate", subjectClass(unread)].join(" ")}>{conversation.subject || "(no subject)"}</div>
|
|
190
191
|
<div className="text-xs text-text-muted truncate font-normal">
|
|
191
|
-
{
|
|
192
|
+
{/* An encrypted latest message has no preview (the server never had its plaintext): say so, with a lock, not nothing. */}
|
|
193
|
+
{conversationLooksEncrypted(conversation) ? <EncryptedPreview /> : conversation.latestPreview}
|
|
192
194
|
</div>
|
|
193
195
|
<div className="flex items-center gap-2 mt-1 text-xs text-text-muted font-normal">
|
|
194
196
|
{conversation.messageCount > 1 && <span>{conversation.messageCount} messages</span>}
|
|
@@ -244,7 +246,7 @@ export default function ConversationList({
|
|
|
244
246
|
</span>
|
|
245
247
|
</div>
|
|
246
248
|
<div className="flex items-center gap-2 text-xs text-text-muted font-normal">
|
|
247
|
-
<span className="truncate">{message.bodyPreview}</span>
|
|
249
|
+
<span className="truncate">{message.bodyPreview || (message.encrypted ? <EncryptedPreview /> : null)}</span>
|
|
248
250
|
{message.hasAttachments && (
|
|
249
251
|
<HiOutlinePaperClip size={12} aria-label="Has attachments" />
|
|
250
252
|
)}
|