@rapidmx/web-client 0.9.0 → 0.10.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 +192 -88
- package/apps/shared/auth/accountUrl.ts +9 -0
- package/apps/shared/auth/adminAccess.ts +99 -0
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +2 -1
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
- package/apps/shared/components/contacts/ContactsToolbar.tsx +125 -115
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +15 -3
- package/apps/shared/components/layout/AppShell.tsx +411 -288
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
- package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -164
- package/apps/shared/components/layout/UserMenu.tsx +303 -185
- package/apps/shared/components/mail/ConversationList.tsx +269 -262
- package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
- package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
- package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1387
- package/apps/shared/components/mail/NewMailToasts.tsx +141 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +279 -160
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1842 -1736
- package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
- package/apps/shared/components/mail/compose/composePerf.ts +46 -0
- package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
- package/apps/shared/components/mail/layout/MailShell.tsx +476 -472
- package/apps/shared/components/mail/unreadStyle.tsx +75 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +252 -240
- package/apps/shared/components/tasks/layout/TasksShell.tsx +15 -3
- package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
- package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
- package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
- package/apps/shared/keyboard/dispatch.ts +124 -0
- package/apps/shared/keyboard/format.ts +89 -0
- package/apps/shared/keyboard/keymap.ts +114 -0
- package/apps/shared/keyboard/match.ts +44 -0
- package/apps/shared/keyboard/parse.ts +136 -0
- package/apps/shared/keyboard/platform.ts +34 -0
- package/apps/shared/keyboard/registry.ts +65 -0
- package/apps/shared/keyboard/targets.ts +79 -0
- package/apps/shared/keyboard/useShortcut.ts +50 -0
- package/apps/shared/keyboard/useShortcutProps.ts +17 -0
- package/apps/shared/mail/folderCounts.ts +302 -0
- package/apps/shared/mail/listSnapshots.ts +87 -0
- package/apps/shared/mail/messageReadState.ts +85 -0
- package/apps/shared/mail/newMailNotifications.ts +183 -0
- package/apps/shared/mail/useMailConnection.ts +150 -0
- package/apps/shared/mail/useMailLiveUpdates.ts +232 -224
- package/apps/shared/mail/useMarkMessageRead.ts +47 -0
- package/apps/shared/mail/useNewMailNotifications.ts +163 -0
- package/apps/shared/mail/useUnreadTitle.ts +42 -0
- package/apps/shared/navigation/AppRouter.tsx +300 -0
- package/apps/shared/navigation/appHrefs.ts +23 -0
- package/apps/shared/navigation/frameContext.tsx +35 -0
- package/apps/shared/navigation/idle.ts +45 -0
- package/apps/shared/navigation/routerContext.tsx +83 -0
- package/apps/shared/navigation/routes.ts +72 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
- package/apps/shared/styles/app.css +28 -10
- package/apps/www/_routedPage.tsx +24 -0
- package/apps/www/_routes.ts +35 -0
- package/apps/www/calendar/index.tsx +55 -19
- package/apps/www/contacts/[uid].tsx +112 -107
- package/apps/www/contacts/index.tsx +584 -567
- package/apps/www/index.tsx +2280 -1917
- package/apps/www/messages/[uid].tsx +106 -101
- package/apps/www/settings/auto-reply/index.tsx +4 -1
- package/apps/www/settings/encryption/index.tsx +1252 -1249
- package/apps/www/settings/filters/[uid].tsx +4 -1
- package/apps/www/settings/filters/index.tsx +102 -99
- package/apps/www/settings/filters/new/index.tsx +138 -133
- package/apps/www/settings/labels/index.tsx +204 -201
- package/apps/www/settings/privacy/index.tsx +4 -1
- package/apps/www/settings/read-receipts/index.tsx +4 -1
- package/apps/www/settings/sharing/index.tsx +277 -274
- package/apps/www/settings/signatures/[uid].tsx +170 -167
- package/apps/www/settings/signatures/index.tsx +88 -85
- package/apps/www/settings/signatures/new/index.tsx +134 -129
- package/apps/www/tasks/index.tsx +18 -2
- package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
- package/dist/apps/shared/auth/accountUrl.js +8 -0
- package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
- package/dist/apps/shared/auth/adminAccess.js +89 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +1 -1
- package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +7 -4
- package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +15 -5
- package/dist/apps/shared/components/layout/AppShell.d.ts +29 -4
- package/dist/apps/shared/components/layout/AppShell.js +73 -14
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +3 -1
- package/dist/apps/shared/components/layout/UserMenu.d.ts +19 -3
- package/dist/apps/shared/components/layout/UserMenu.js +52 -5
- package/dist/apps/shared/components/mail/ConversationList.js +4 -11
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +52 -27
- package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
- package/dist/apps/shared/components/mail/LazyReadingPane.js +82 -0
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +4 -1
- package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -3
- package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +7 -1
- package/dist/apps/shared/components/mail/MessageDetailPane.js +83 -34
- package/dist/apps/shared/components/mail/NewMailToasts.d.ts +19 -0
- package/dist/apps/shared/components/mail/NewMailToasts.js +55 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +30 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +71 -5
- package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
- package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +1 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +98 -22
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
- package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
- package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +12 -4
- package/dist/apps/shared/components/mail/layout/MailShell.js +76 -96
- package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
- package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +15 -5
- package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +15 -5
- package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
- package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
- package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
- package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
- package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
- package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
- package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
- package/dist/apps/shared/keyboard/dispatch.js +109 -0
- package/dist/apps/shared/keyboard/format.d.ts +12 -0
- package/dist/apps/shared/keyboard/format.js +74 -0
- package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
- package/dist/apps/shared/keyboard/keymap.js +84 -0
- package/dist/apps/shared/keyboard/match.d.ts +20 -0
- package/dist/apps/shared/keyboard/match.js +31 -0
- package/dist/apps/shared/keyboard/parse.d.ts +31 -0
- package/dist/apps/shared/keyboard/parse.js +107 -0
- package/dist/apps/shared/keyboard/platform.d.ts +15 -0
- package/dist/apps/shared/keyboard/platform.js +22 -0
- package/dist/apps/shared/keyboard/registry.d.ts +39 -0
- package/dist/apps/shared/keyboard/registry.js +33 -0
- package/dist/apps/shared/keyboard/targets.d.ts +14 -0
- package/dist/apps/shared/keyboard/targets.js +67 -0
- package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
- package/dist/apps/shared/keyboard/useShortcut.js +35 -0
- package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
- package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
- package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
- package/dist/apps/shared/mail/folderCounts.js +212 -0
- package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
- package/dist/apps/shared/mail/listSnapshots.js +43 -0
- package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
- package/dist/apps/shared/mail/messageReadState.js +63 -0
- package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
- package/dist/apps/shared/mail/newMailNotifications.js +138 -0
- package/dist/apps/shared/mail/useMailConnection.d.ts +55 -0
- package/dist/apps/shared/mail/useMailConnection.js +96 -0
- package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +13 -6
- package/dist/apps/shared/mail/useMailLiveUpdates.js +28 -26
- package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
- package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
- package/dist/apps/shared/mail/useNewMailNotifications.d.ts +41 -0
- package/dist/apps/shared/mail/useNewMailNotifications.js +110 -0
- package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
- package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
- package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
- package/dist/apps/shared/navigation/AppRouter.js +242 -0
- package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
- package/dist/apps/shared/navigation/appHrefs.js +20 -0
- package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
- package/dist/apps/shared/navigation/frameContext.js +24 -0
- package/dist/apps/shared/navigation/idle.d.ts +14 -0
- package/dist/apps/shared/navigation/idle.js +43 -0
- package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
- package/dist/apps/shared/navigation/routerContext.js +56 -0
- package/dist/apps/shared/navigation/routes.d.ts +32 -0
- package/dist/apps/shared/navigation/routes.js +37 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
- package/dist/apps/shared/styles/app.css +28 -10
- package/dist/apps/www/_routedPage.d.ts +12 -0
- package/dist/apps/www/_routedPage.js +19 -0
- package/dist/apps/www/_routes.d.ts +11 -0
- package/dist/apps/www/_routes.js +29 -0
- package/dist/apps/www/calendar/index.d.ts +2 -2
- package/dist/apps/www/calendar/index.js +39 -8
- package/dist/apps/www/contacts/[uid].d.ts +3 -9
- package/dist/apps/www/contacts/[uid].js +6 -2
- package/dist/apps/www/contacts/index.d.ts +2 -2
- package/dist/apps/www/contacts/index.js +17 -4
- package/dist/apps/www/index.d.ts +2 -2
- package/dist/apps/www/index.js +301 -35
- package/dist/apps/www/messages/[uid].d.ts +3 -7
- package/dist/apps/www/messages/[uid].js +7 -4
- package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
- package/dist/apps/www/settings/auto-reply/index.js +3 -1
- package/dist/apps/www/settings/encryption/index.d.ts +2 -2
- package/dist/apps/www/settings/encryption/index.js +3 -1
- package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
- package/dist/apps/www/settings/filters/[uid].js +3 -1
- package/dist/apps/www/settings/filters/index.d.ts +2 -2
- package/dist/apps/www/settings/filters/index.js +3 -1
- package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
- package/dist/apps/www/settings/filters/new/index.js +6 -2
- package/dist/apps/www/settings/labels/index.d.ts +2 -2
- package/dist/apps/www/settings/labels/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.d.ts +2 -2
- package/dist/apps/www/settings/privacy/index.js +3 -1
- package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
- package/dist/apps/www/settings/read-receipts/index.js +3 -1
- package/dist/apps/www/settings/sharing/index.d.ts +2 -2
- package/dist/apps/www/settings/sharing/index.js +3 -1
- package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
- package/dist/apps/www/settings/signatures/[uid].js +3 -1
- package/dist/apps/www/settings/signatures/index.d.ts +2 -2
- package/dist/apps/www/settings/signatures/index.js +3 -1
- package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
- package/dist/apps/www/settings/signatures/new/index.js +6 -2
- package/dist/apps/www/tasks/index.d.ts +2 -2
- package/dist/apps/www/tasks/index.js +15 -3
- package/package.json +2 -2
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useRef, useState } from "react";
|
|
7
|
+
import { HiOutlineXMark } from "react-icons/hi2";
|
|
8
|
+
import { TOAST_DURATION_MS } from "../../mail/useNewMailNotifications.js";
|
|
9
|
+
/** Whether the tab is out of view - a pop-up's clock stops meanwhile, so it is still there when the reader comes back. */
|
|
10
|
+
function useTabHidden() {
|
|
11
|
+
const [hidden, setHidden] = useState(false);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const update = () => setHidden(document.visibilityState === "hidden");
|
|
14
|
+
update();
|
|
15
|
+
document.addEventListener("visibilitychange", update);
|
|
16
|
+
return () => document.removeEventListener("visibilitychange", update);
|
|
17
|
+
}, []);
|
|
18
|
+
return hidden;
|
|
19
|
+
}
|
|
20
|
+
/** One pop-up. Its 8 seconds only run while it is neither hovered nor holding keyboard focus, and the tab is in view. */
|
|
21
|
+
function ToastItem({ toast, tabHidden, onDismiss, offer }) {
|
|
22
|
+
const [hovered, setHovered] = useState(false);
|
|
23
|
+
const [focused, setFocused] = useState(false);
|
|
24
|
+
const remainingRef = useRef(TOAST_DURATION_MS);
|
|
25
|
+
const paused = tabHidden || hovered || focused;
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (paused) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const startedAt = Date.now();
|
|
31
|
+
const timer = setTimeout(() => onDismiss(toast.uid), remainingRef.current);
|
|
32
|
+
return () => {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
remainingRef.current = Math.max(0, remainingRef.current - (Date.now() - startedAt));
|
|
35
|
+
};
|
|
36
|
+
}, [paused, toast.uid, onDismiss]);
|
|
37
|
+
const sender = toast.senderName || toast.senderAddress;
|
|
38
|
+
return (_jsxs("div", { "data-testid": "new-mail-toast", onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), onFocus: () => setFocused(true), onBlur: (event) => {
|
|
39
|
+
// Focus moving between this pop-up's own controls is not leaving it.
|
|
40
|
+
if (!event.currentTarget.contains(event.relatedTarget)) {
|
|
41
|
+
setFocused(false);
|
|
42
|
+
}
|
|
43
|
+
}, className: "rr-toast-in pointer-events-auto relative rounded-md border border-border border-l-4 border-l-primary bg-surface text-text shadow-modal", children: [_jsxs("a", { href: toast.href, onClick: () => onDismiss(toast.uid), className: "block rounded-md p-3 pr-10 hover:bg-surface-alt focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary", children: [_jsx("span", { className: "block text-xs font-semibold uppercase tracking-wide text-primary-dark", children: "New message" }), _jsxs("span", { className: "mt-0.5 flex min-w-0 items-baseline gap-1.5 text-sm", children: [_jsx("span", { className: "truncate font-bold", children: sender }), toast.senderName && _jsxs("span", { className: "truncate text-xs text-text-muted", children: ["<", toast.senderAddress, ">"] })] }), _jsx("span", { className: "block truncate text-sm font-semibold", children: toast.subject }), toast.preview && _jsx("span", { className: "mt-0.5 block text-xs text-text-muted [overflow-wrap:anywhere]", children: toast.preview })] }), _jsx("button", { type: "button", onClick: () => onDismiss(toast.uid), "aria-label": `Dismiss notification: ${toast.subject}`, className: "absolute right-1.5 top-1.5 flex h-7 w-7 items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text focus-visible:outline-2 focus-visible:outline-primary", children: _jsx(HiOutlineXMark, { size: 16, "aria-hidden": "true" }) }), offer && (_jsxs("div", { className: "flex flex-wrap items-center gap-2 border-t border-border px-3 py-2 text-xs text-text-muted", children: [_jsx("span", { className: "basis-full", children: "Get a desktop notification when mail arrives while this tab is in the background." }), _jsx("button", { type: "button", onClick: offer.onEnable, className: "rounded-sm bg-primary px-2.5 py-1 text-xs font-semibold text-text-on-primary hover:bg-primary-dark", children: "Turn on desktop notifications" }), _jsx("button", { type: "button", onClick: offer.onDecline, className: "rounded-sm px-2 py-1 text-xs font-medium hover:bg-surface-alt hover:text-text", children: "Not now" })] }))] }));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The new-mail pop-ups, stacked at the top right (`fixed`, above everything). The container is always rendered - a live region
|
|
47
|
+
* has to exist before its content changes to be announced - and is a polite `status` region, so an arrival is read out without
|
|
48
|
+
* interrupting. Each pop-up shows the sender's name and address, the subject and a short preview, as text; clicking it opens the
|
|
49
|
+
* message; it has a dismiss button and goes by itself after a few seconds (see `ToastItem`). Motion is CSS-only and switched off
|
|
50
|
+
* by `prefers-reduced-motion` (`.rr-toast-in` in `app.css`).
|
|
51
|
+
*/
|
|
52
|
+
export default function NewMailToasts({ toasts, onDismiss, offerDesktop, onEnableDesktop, onDeclineDesktop }) {
|
|
53
|
+
const tabHidden = useTabHidden();
|
|
54
|
+
return (_jsx("div", { role: "status", "aria-live": "polite", "aria-label": "New mail", "data-testid": "new-mail-toasts", className: "pointer-events-none fixed right-4 top-4 z-50 flex w-[22rem] max-w-[calc(100vw-2rem)] flex-col gap-2", children: toasts.map((toast, index) => (_jsx(ToastItem, { toast: toast, tabHidden: tabHidden, onDismiss: onDismiss, offer: offerDesktop && index === 0 ? { onEnable: onEnableDesktop, onDecline: onDeclineDesktop } : undefined }, toast.uid))) }));
|
|
55
|
+
}
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import React, { PropsWithChildren } from "react";
|
|
2
2
|
import { DraftThreading } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
3
|
+
/**
|
|
4
|
+
* Starts downloading the compose window's code without opening one - on hover/focus of a Compose or Reply button and when
|
|
5
|
+
* the browser is idle after the inbox has loaded - so that clicking it finds the chunk already in memory. Safe to call
|
|
6
|
+
* any number of times, and never rejects: a failed download is retried by the click itself.
|
|
7
|
+
*/
|
|
8
|
+
export declare function prefetchComposeWindow(): void;
|
|
9
|
+
/** Test seam: forgets the loaded compose window chunk, as a fresh page load would. */
|
|
10
|
+
export declare function resetComposeWindowLoader(): void;
|
|
3
11
|
export interface ComposeSession {
|
|
4
12
|
id: string;
|
|
5
13
|
/** The initial sending mailbox - absent means "the caller's own mailbox" (see `ComposeWindow`). */
|
|
@@ -20,8 +28,21 @@ export interface ComposeSession {
|
|
|
20
28
|
signatureContext: "new" | "reply_forward";
|
|
21
29
|
/** See `OpenComposeInput.suppressSigning`'s own doc comment. */
|
|
22
30
|
suppressSigning?: boolean;
|
|
31
|
+
/** The window opened before its quoted original was known (see `OpenComposeInput.pending`): the body waits for it. */
|
|
32
|
+
quotePending?: boolean;
|
|
33
|
+
/** What `OpenComposeInput.pending` resolved with, once it has - see `ComposeLateInput`. */
|
|
34
|
+
late?: ComposeLateInput;
|
|
23
35
|
minimized: boolean;
|
|
24
36
|
}
|
|
37
|
+
/** What a reply or forward could only work out after its window was already open (see `OpenComposeInput.pending`). */
|
|
38
|
+
export interface ComposeLateInput {
|
|
39
|
+
/** The quoted original - see `OpenComposeInput.quotedHtml`. */
|
|
40
|
+
quotedHtml?: string;
|
|
41
|
+
/** A better To than the one the window opened with - applied only if the To field is still exactly what it opened with. */
|
|
42
|
+
to?: string;
|
|
43
|
+
/** A better Cc, under the same rule as `to`. */
|
|
44
|
+
cc?: string;
|
|
45
|
+
}
|
|
25
46
|
export interface OpenComposeInput {
|
|
26
47
|
/** The sending mailbox to start with. Omit for a fresh message (defaults to the caller's own mailbox);
|
|
27
48
|
* a reply/forward passes the original message's mailbox so a shared mailbox's mail replies from it.
|
|
@@ -53,6 +74,15 @@ export interface OpenComposeInput {
|
|
|
53
74
|
* `In-Reply-To`/`References` at all and every mail system - the sender's own Sent Items included - files it
|
|
54
75
|
* as a new conversation rather than part of the thread. Absent for a fresh compose, which starts one. */
|
|
55
76
|
threading?: DraftThreading;
|
|
77
|
+
/**
|
|
78
|
+
* Something the window shouldn't wait for before appearing: a reply or forward opens at once from what is already in
|
|
79
|
+
* memory (the message's own subject and sender), and this settles with what needed the network - the original's body
|
|
80
|
+
* to quote, and for Reply All the recipients its headers name. The compose window shows meanwhile, its body area
|
|
81
|
+
* waiting, and fills the body in when this resolves; `to`/`cc` replace the opened-with values only if the user hasn't
|
|
82
|
+
* touched those fields. Must never reject (resolve `undefined` for "nothing more"); when set, `quotedHtml` is ignored
|
|
83
|
+
* until it resolves.
|
|
84
|
+
*/
|
|
85
|
+
pending?: Promise<ComposeLateInput | undefined>;
|
|
56
86
|
}
|
|
57
87
|
export interface ComposeContextValue {
|
|
58
88
|
/** Opens a new compose window, stacked alongside any already open (Gmail allows several at once). */
|
|
@@ -3,9 +3,67 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
-
import { createContext, useContext, useMemo, useState } from "react";
|
|
6
|
+
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
7
7
|
import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
|
|
8
|
-
import
|
|
8
|
+
import { markComposePhase } from "./composePerf.js";
|
|
9
|
+
import ComposeWindowPlaceholder from "./ComposeWindowPlaceholder.js";
|
|
10
|
+
let loadedComposeWindow;
|
|
11
|
+
let loadingComposeWindow;
|
|
12
|
+
/**
|
|
13
|
+
* The compose window - with the rich-text editor (TipTap/ProseMirror), the recipient inputs and the send pipeline - is
|
|
14
|
+
* the largest piece of the mail app and most page loads never open it, so it is a separate chunk, loaded here once. Not
|
|
15
|
+
* `React.lazy()`: a chunk that failed to download (offline, a deploy that replaced it) must be retryable, and `lazy()` keeps
|
|
16
|
+
* the rejection for good.
|
|
17
|
+
*/
|
|
18
|
+
function loadComposeWindow() {
|
|
19
|
+
loadingComposeWindow ?? (loadingComposeWindow = import("./ComposeWindow.js").then((module) => (loadedComposeWindow = module.default), (err) => {
|
|
20
|
+
loadingComposeWindow = undefined;
|
|
21
|
+
throw err;
|
|
22
|
+
}));
|
|
23
|
+
return loadingComposeWindow;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Starts downloading the compose window's code without opening one - on hover/focus of a Compose or Reply button and when
|
|
27
|
+
* the browser is idle after the inbox has loaded - so that clicking it finds the chunk already in memory. Safe to call
|
|
28
|
+
* any number of times, and never rejects: a failed download is retried by the click itself.
|
|
29
|
+
*/
|
|
30
|
+
export function prefetchComposeWindow() {
|
|
31
|
+
loadComposeWindow().catch(() => undefined);
|
|
32
|
+
}
|
|
33
|
+
/** Test seam: forgets the loaded compose window chunk, as a fresh page load would. */
|
|
34
|
+
export function resetComposeWindowLoader() {
|
|
35
|
+
loadedComposeWindow = undefined;
|
|
36
|
+
loadingComposeWindow = undefined;
|
|
37
|
+
}
|
|
38
|
+
/** The compose window component once its chunk is in, else `undefined` (while loading, or `failed`), and a way to retry. */
|
|
39
|
+
function useComposeWindowComponent(wanted) {
|
|
40
|
+
const [loaded, setComponent] = useState();
|
|
41
|
+
// Also read straight from the module: a prefetch that finished after this provider mounted has no state update to
|
|
42
|
+
// announce it, and the first click must not flash the placeholder for a chunk that is already here.
|
|
43
|
+
const Component = loaded ?? loadedComposeWindow;
|
|
44
|
+
const [failed, setFailed] = useState(false);
|
|
45
|
+
const [attempt, setAttempt] = useState(0);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!wanted || Component) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
let cancelled = false;
|
|
51
|
+
setFailed(false);
|
|
52
|
+
loadComposeWindow().then((component) => {
|
|
53
|
+
if (!cancelled) {
|
|
54
|
+
setComponent(() => component);
|
|
55
|
+
}
|
|
56
|
+
}, () => {
|
|
57
|
+
if (!cancelled) {
|
|
58
|
+
setFailed(true);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
return () => {
|
|
62
|
+
cancelled = true;
|
|
63
|
+
};
|
|
64
|
+
}, [wanted, Component, attempt]);
|
|
65
|
+
return { Component, failed, retry: () => setAttempt((n) => n + 1) };
|
|
66
|
+
}
|
|
9
67
|
const ComposeContext = createContext({ openCompose: () => undefined });
|
|
10
68
|
/** Opens the floating Compose window from anywhere inside `AppShell` (any of the four webmail apps). */
|
|
11
69
|
export function useCompose() {
|
|
@@ -21,11 +79,14 @@ export function useCompose() {
|
|
|
21
79
|
export default function ComposeProvider({ children, userUid, trusted }) {
|
|
22
80
|
const [sessions, setSessions] = useState([]);
|
|
23
81
|
const isMobile = useIsMobile();
|
|
24
|
-
|
|
82
|
+
const { Component: ComposeWindow, failed, retry: retryLoad } = useComposeWindowComponent(sessions.length > 0);
|
|
83
|
+
function openCompose({ mailboxUid, to, cc, subject, quotedHtml, signatureContext = "new", suppressSigning, encrypt, threading, pending, }) {
|
|
84
|
+
const id = crypto.randomUUID();
|
|
85
|
+
markComposePhase(id, "click");
|
|
25
86
|
setSessions((prev) => [
|
|
26
87
|
...prev,
|
|
27
88
|
{
|
|
28
|
-
id
|
|
89
|
+
id,
|
|
29
90
|
mailboxUid,
|
|
30
91
|
initialTo: to,
|
|
31
92
|
initialCc: cc,
|
|
@@ -35,9 +96,14 @@ export default function ComposeProvider({ children, userUid, trusted }) {
|
|
|
35
96
|
threading,
|
|
36
97
|
signatureContext,
|
|
37
98
|
suppressSigning,
|
|
99
|
+
quotePending: !!pending,
|
|
38
100
|
minimized: false,
|
|
39
101
|
},
|
|
40
102
|
]);
|
|
103
|
+
// A window closed before this settles is simply not in the list any more - nothing to update.
|
|
104
|
+
void pending
|
|
105
|
+
?.catch(() => undefined)
|
|
106
|
+
.then((late) => setSessions((prev) => prev.map((s) => s.id === id ? { ...s, quotePending: false, late, initialQuotedHtml: late?.quotedHtml ?? s.initialQuotedHtml } : s)));
|
|
41
107
|
}
|
|
42
108
|
function closeCompose(id) {
|
|
43
109
|
setSessions((prev) => prev.filter((s) => s.id !== id));
|
|
@@ -56,6 +122,6 @@ export default function ComposeProvider({ children, userUid, trusted }) {
|
|
|
56
122
|
const lastNonMinimizedId = isMobile ? [...sessions].reverse().find((s) => !s.minimized)?.id : undefined;
|
|
57
123
|
return (_jsxs(ComposeContext.Provider, { value: value, children: [children, sessions.length > 0 && (_jsx("div", { className: "fixed bottom-0 right-6 flex items-end gap-3 z-50", children: sessions.map((session) => {
|
|
58
124
|
const hidden = isMobile && !session.minimized && session.id !== lastNonMinimizedId;
|
|
59
|
-
return (_jsx("div", { hidden: hidden, className: hidden ? "hidden" : "contents", children: _jsx(ComposeWindow, { session: session, userUid: userUid, trusted: trusted, onClose: () => closeCompose(session.id), onToggleMinimize: () => toggleMinimize(session.id) }) }, session.id));
|
|
125
|
+
return (_jsx("div", { hidden: hidden, className: hidden ? "hidden" : "contents", children: ComposeWindow ? (_jsx(ComposeWindow, { session: session, userUid: userUid, trusted: trusted, onClose: () => closeCompose(session.id), onToggleMinimize: () => toggleMinimize(session.id) })) : (_jsx(ComposeWindowPlaceholder, { session: session, failed: failed, onRetry: retryLoad, onClose: () => closeCompose(session.id), onToggleMinimize: () => toggleMinimize(session.id) })) }, session.id));
|
|
60
126
|
}) }))] }));
|
|
61
127
|
}
|
|
@@ -3,10 +3,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
-
import { useRef, useState } from "react";
|
|
6
|
+
import { Suspense, lazy, useRef, useState } from "react";
|
|
7
7
|
import { BsArrowClockwise, BsArrowCounterclockwise, BsEraser, BsHighlighter, BsImage, BsIndent, BsJustify, BsLink45Deg, BsListOl, BsListUl, BsPaletteFill, BsTable, BsTextCenter, BsTextLeft, BsTextRight, BsTypeBold, BsTypeItalic, BsTypeStrikethrough, BsTypeUnderline, BsUnindent, } from "react-icons/bs";
|
|
8
|
-
import EmojiPicker from "./EmojiPicker.js";
|
|
9
8
|
import GifPicker from "./GifPicker.js";
|
|
9
|
+
/** Loaded when the picker is first opened: the emoji list is a half-megabyte JSON file that nothing else needs. */
|
|
10
|
+
const EmojiPicker = lazy(() => import("./EmojiPicker.js"));
|
|
10
11
|
const FONT_FAMILIES = [
|
|
11
12
|
{ label: "Default", value: "" },
|
|
12
13
|
{ label: "Sans Serif", value: "Arial, Helvetica, sans-serif" },
|
|
@@ -84,5 +85,5 @@ export default function ComposeToolbar({ editor, onUploadImage }) {
|
|
|
84
85
|
? ed.chain().focus().setFontFamily(e.target.value).run()
|
|
85
86
|
: ed.chain().focus().unsetFontFamily().run()), className: "text-xs py-1 px-1.5 border border-border rounded-sm bg-surface disabled:opacity-40", children: FONT_FAMILIES.map((f) => (_jsx("option", { value: f.value, children: f.label }, f.label))) }), _jsx("select", { "aria-label": "Font size", disabled: disabled, value: editor?.getAttributes("textStyle").fontSize ?? "", onChange: (e) => run((ed) => e.target.value
|
|
86
87
|
? ed.chain().focus().setFontSize(e.target.value).run()
|
|
87
|
-
: ed.chain().focus().unsetFontSize().run()), className: "text-xs py-1 px-1.5 border border-border rounded-sm bg-surface disabled:opacity-40", children: FONT_SIZES.map((f) => (_jsx("option", { value: f.value, children: f.label }, f.label))) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Bold", icon: BsTypeBold, disabled: disabled, active: editor?.isActive("bold"), onClick: () => run((e) => e.chain().focus().toggleBold().run()) }), _jsx(ToolbarButton, { label: "Italic", icon: BsTypeItalic, disabled: disabled, active: editor?.isActive("italic"), onClick: () => run((e) => e.chain().focus().toggleItalic().run()) }), _jsx(ToolbarButton, { label: "Underline", icon: BsTypeUnderline, disabled: disabled, active: editor?.isActive("underline"), onClick: () => run((e) => e.chain().focus().toggleUnderline().run()) }), _jsx(ToolbarButton, { label: "Strikethrough", icon: BsTypeStrikethrough, disabled: disabled, active: editor?.isActive("strike"), onClick: () => run((e) => e.chain().focus().toggleStrike().run()) }), _jsx(Divider, {}), _jsxs("label", { className: "w-8 h-8 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed", children: [_jsx(BsPaletteFill, { size: 16, "aria-hidden": "true" }), _jsx("span", { className: "sr-only", children: "Text color" }), _jsx("input", { type: "color", disabled: disabled, value: editor?.getAttributes("textStyle").color ?? "#000000", onChange: (e) => run((ed) => ed.chain().focus().setColor(e.target.value).run()), className: "sr-only" })] }), _jsx(ToolbarButton, { label: "Highlight", icon: BsHighlighter, disabled: disabled, active: editor?.isActive("highlight"), onClick: () => run((e) => e.chain().focus().toggleHighlight().run()) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Align left", icon: BsTextLeft, disabled: disabled, active: editor?.isActive({ textAlign: "left" }), onClick: () => run((e) => e.chain().focus().setTextAlign("left").run()) }), _jsx(ToolbarButton, { label: "Align center", icon: BsTextCenter, disabled: disabled, active: editor?.isActive({ textAlign: "center" }), onClick: () => run((e) => e.chain().focus().setTextAlign("center").run()) }), _jsx(ToolbarButton, { label: "Align right", icon: BsTextRight, disabled: disabled, active: editor?.isActive({ textAlign: "right" }), onClick: () => run((e) => e.chain().focus().setTextAlign("right").run()) }), _jsx(ToolbarButton, { label: "Justify", icon: BsJustify, disabled: disabled, active: editor?.isActive({ textAlign: "justify" }), onClick: () => run((e) => e.chain().focus().setTextAlign("justify").run()) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Bulleted list", icon: BsListUl, disabled: disabled, active: editor?.isActive("bulletList"), onClick: () => run((e) => e.chain().focus().toggleBulletList().run()) }), _jsx(ToolbarButton, { label: "Numbered list", icon: BsListOl, disabled: disabled, active: editor?.isActive("orderedList"), onClick: () => run((e) => e.chain().focus().toggleOrderedList().run()) }), _jsx(ToolbarButton, { label: "Decrease indent", icon: BsUnindent, disabled: disabled, onClick: () => run((e) => e.chain().focus().liftListItem("listItem").run()) }), _jsx(ToolbarButton, { label: "Increase indent", icon: BsIndent, disabled: disabled, onClick: () => run((e) => e.chain().focus().sinkListItem("listItem").run()) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Insert link", icon: BsLink45Deg, disabled: disabled, active: editor?.isActive("link"), onClick: openLinkPrompt }), _jsx(ToolbarButton, { label: "Insert image", icon: BsImage, disabled: disabled, onClick: () => imageInputRef.current?.click() }), _jsx("input", { ref: imageInputRef, type: "file", accept: "image/*", onChange: handleImageFileSelected, className: "sr-only", "aria-label": "Insert image file" }), _jsx(ToolbarButton, { label: "Insert table", icon: BsTable, disabled: disabled, onClick: () => run((e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()) }), _jsx("button", { ref: emojiButtonRef, type: "button", "aria-label": "Insert emoji", title: "Insert emoji", disabled: disabled, onClick: () => setOpenPopup((p) => (p === "emoji" ? null : "emoji")), className: "w-8 h-8 flex items-center justify-center text-base rounded-sm disabled:opacity-40 disabled:cursor-not-allowed text-text-muted hover:bg-surface-alt hover:text-text", children: "\uD83D\uDE00" }), openPopup === "emoji" && (_jsx(EmojiPicker, { anchorRef: emojiButtonRef, onSelect: handleInsertEmoji, onClose: () => setOpenPopup(null) })), _jsx("button", { ref: gifButtonRef, type: "button", "aria-label": "Insert GIF", title: "Insert GIF", disabled: disabled, onClick: () => setOpenPopup((p) => (p === "gif" ? null : "gif")), className: "w-8 h-8 flex items-center justify-center text-[10px] font-extrabold tracking-tight rounded-sm disabled:opacity-40 disabled:cursor-not-allowed text-text-muted hover:bg-surface-alt hover:text-text", children: "GIF" }), openPopup === "gif" && _jsx(GifPicker, { anchorRef: gifButtonRef, onSelect: handleInsertGif, onClose: () => setOpenPopup(null) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Clear formatting", icon: BsEraser, disabled: disabled, onClick: () => run((e) => e.chain().focus().unsetAllMarks().clearNodes().run()) }), _jsx(ToolbarButton, { label: "Undo", icon: BsArrowCounterclockwise, disabled: disabled || !editor?.can().undo(), onClick: () => run((e) => e.chain().focus().undo().run()) }), _jsx(ToolbarButton, { label: "Redo", icon: BsArrowClockwise, disabled: disabled || !editor?.can().redo(), onClick: () => run((e) => e.chain().focus().redo().run()) })] }), linkPrompt] }));
|
|
88
|
+
: ed.chain().focus().unsetFontSize().run()), className: "text-xs py-1 px-1.5 border border-border rounded-sm bg-surface disabled:opacity-40", children: FONT_SIZES.map((f) => (_jsx("option", { value: f.value, children: f.label }, f.label))) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Bold", icon: BsTypeBold, disabled: disabled, active: editor?.isActive("bold"), onClick: () => run((e) => e.chain().focus().toggleBold().run()) }), _jsx(ToolbarButton, { label: "Italic", icon: BsTypeItalic, disabled: disabled, active: editor?.isActive("italic"), onClick: () => run((e) => e.chain().focus().toggleItalic().run()) }), _jsx(ToolbarButton, { label: "Underline", icon: BsTypeUnderline, disabled: disabled, active: editor?.isActive("underline"), onClick: () => run((e) => e.chain().focus().toggleUnderline().run()) }), _jsx(ToolbarButton, { label: "Strikethrough", icon: BsTypeStrikethrough, disabled: disabled, active: editor?.isActive("strike"), onClick: () => run((e) => e.chain().focus().toggleStrike().run()) }), _jsx(Divider, {}), _jsxs("label", { className: "w-8 h-8 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed", children: [_jsx(BsPaletteFill, { size: 16, "aria-hidden": "true" }), _jsx("span", { className: "sr-only", children: "Text color" }), _jsx("input", { type: "color", disabled: disabled, value: editor?.getAttributes("textStyle").color ?? "#000000", onChange: (e) => run((ed) => ed.chain().focus().setColor(e.target.value).run()), className: "sr-only" })] }), _jsx(ToolbarButton, { label: "Highlight", icon: BsHighlighter, disabled: disabled, active: editor?.isActive("highlight"), onClick: () => run((e) => e.chain().focus().toggleHighlight().run()) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Align left", icon: BsTextLeft, disabled: disabled, active: editor?.isActive({ textAlign: "left" }), onClick: () => run((e) => e.chain().focus().setTextAlign("left").run()) }), _jsx(ToolbarButton, { label: "Align center", icon: BsTextCenter, disabled: disabled, active: editor?.isActive({ textAlign: "center" }), onClick: () => run((e) => e.chain().focus().setTextAlign("center").run()) }), _jsx(ToolbarButton, { label: "Align right", icon: BsTextRight, disabled: disabled, active: editor?.isActive({ textAlign: "right" }), onClick: () => run((e) => e.chain().focus().setTextAlign("right").run()) }), _jsx(ToolbarButton, { label: "Justify", icon: BsJustify, disabled: disabled, active: editor?.isActive({ textAlign: "justify" }), onClick: () => run((e) => e.chain().focus().setTextAlign("justify").run()) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Bulleted list", icon: BsListUl, disabled: disabled, active: editor?.isActive("bulletList"), onClick: () => run((e) => e.chain().focus().toggleBulletList().run()) }), _jsx(ToolbarButton, { label: "Numbered list", icon: BsListOl, disabled: disabled, active: editor?.isActive("orderedList"), onClick: () => run((e) => e.chain().focus().toggleOrderedList().run()) }), _jsx(ToolbarButton, { label: "Decrease indent", icon: BsUnindent, disabled: disabled, onClick: () => run((e) => e.chain().focus().liftListItem("listItem").run()) }), _jsx(ToolbarButton, { label: "Increase indent", icon: BsIndent, disabled: disabled, onClick: () => run((e) => e.chain().focus().sinkListItem("listItem").run()) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Insert link", icon: BsLink45Deg, disabled: disabled, active: editor?.isActive("link"), onClick: openLinkPrompt }), _jsx(ToolbarButton, { label: "Insert image", icon: BsImage, disabled: disabled, onClick: () => imageInputRef.current?.click() }), _jsx("input", { ref: imageInputRef, type: "file", accept: "image/*", onChange: handleImageFileSelected, className: "sr-only", "aria-label": "Insert image file" }), _jsx(ToolbarButton, { label: "Insert table", icon: BsTable, disabled: disabled, onClick: () => run((e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()) }), _jsx("button", { ref: emojiButtonRef, type: "button", "aria-label": "Insert emoji", title: "Insert emoji", disabled: disabled, onClick: () => setOpenPopup((p) => (p === "emoji" ? null : "emoji")), className: "w-8 h-8 flex items-center justify-center text-base rounded-sm disabled:opacity-40 disabled:cursor-not-allowed text-text-muted hover:bg-surface-alt hover:text-text", children: "\uD83D\uDE00" }), openPopup === "emoji" && (_jsx(Suspense, { fallback: null, children: _jsx(EmojiPicker, { anchorRef: emojiButtonRef, onSelect: handleInsertEmoji, onClose: () => setOpenPopup(null) }) })), _jsx("button", { ref: gifButtonRef, type: "button", "aria-label": "Insert GIF", title: "Insert GIF", disabled: disabled, onClick: () => setOpenPopup((p) => (p === "gif" ? null : "gif")), className: "w-8 h-8 flex items-center justify-center text-[10px] font-extrabold tracking-tight rounded-sm disabled:opacity-40 disabled:cursor-not-allowed text-text-muted hover:bg-surface-alt hover:text-text", children: "GIF" }), openPopup === "gif" && _jsx(GifPicker, { anchorRef: gifButtonRef, onSelect: handleInsertGif, onClose: () => setOpenPopup(null) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Clear formatting", icon: BsEraser, disabled: disabled, onClick: () => run((e) => e.chain().focus().unsetAllMarks().clearNodes().run()) }), _jsx(ToolbarButton, { label: "Undo", icon: BsArrowCounterclockwise, disabled: disabled || !editor?.can().undo(), onClick: () => run((e) => e.chain().focus().undo().run()) }), _jsx(ToolbarButton, { label: "Redo", icon: BsArrowClockwise, disabled: disabled || !editor?.can().redo(), onClick: () => run((e) => e.chain().focus().redo().run()) })] }), linkPrompt] }));
|
|
88
89
|
}
|
|
@@ -16,9 +16,14 @@ import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/cryp
|
|
|
16
16
|
import { findActivePublicKey, getEncryptionPolicy, lookupKeys } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
17
17
|
import { useUnlockPrompt } from "../../layout/UnlockPromptProvider.js";
|
|
18
18
|
import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
|
|
19
|
-
import { applyBaselineOuterHeaders, assembleOutboundMime, buildEncryptedMessage, buildSignedOnlyMessage } from "@rapidmx/react-shared/crypto/smimeMessage.js";
|
|
20
19
|
import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
|
|
20
|
+
import { useCompose } from "./ComposeContext.js";
|
|
21
|
+
import { ariaKeyShortcuts, withHint } from "../../../keyboard/format.js";
|
|
22
|
+
import { SHORTCUTS } from "../../../keyboard/keymap.js";
|
|
23
|
+
import { useKeyEnvironment } from "../../../keyboard/ShortcutProvider.js";
|
|
24
|
+
import { useShortcut } from "../../../keyboard/useShortcut.js";
|
|
21
25
|
import { isSigningOut, registerComposeFlush } from "./composeFlushRegistry.js";
|
|
26
|
+
import { markComposePhase } from "./composePerf.js";
|
|
22
27
|
import RecipientInput from "./RecipientInput.js";
|
|
23
28
|
import { parseRecipientList } from "./recipients.js";
|
|
24
29
|
import RichTextEditor from "./RichTextEditor.js";
|
|
@@ -80,8 +85,9 @@ const VIEWPORT_MARGIN = 24;
|
|
|
80
85
|
function parseAddresses(value) {
|
|
81
86
|
return parseRecipientList(value);
|
|
82
87
|
}
|
|
83
|
-
function HeaderButton({ label, onClick, icon: Icon, disabled, }) {
|
|
84
|
-
|
|
88
|
+
function HeaderButton({ label, onClick, icon: Icon, disabled, shortcut, }) {
|
|
89
|
+
const env = useKeyEnvironment();
|
|
90
|
+
return (_jsx("button", { type: "button", "aria-label": label, title: shortcut ? withHint(label, shortcut, env) : label, "aria-keyshortcuts": shortcut ? ariaKeyShortcuts(shortcut, env) : undefined, onClick: onClick, disabled: disabled, className: "w-6 h-6 flex items-center justify-center rounded-sm text-white/80 hover:bg-white/15 hover:text-white disabled:opacity-40 disabled:cursor-not-allowed", children: _jsx(Icon, { size: 14 }) }));
|
|
85
91
|
}
|
|
86
92
|
/**
|
|
87
93
|
* A floating, Gmail-style compose window — overlays whatever app is currently showing instead of
|
|
@@ -98,7 +104,15 @@ function HeaderButton({ label, onClick, icon: Icon, disabled, }) {
|
|
|
98
104
|
* once.
|
|
99
105
|
*/
|
|
100
106
|
export default function ComposeWindow({ session, onClose, onToggleMinimize, userUid, trusted, autosaveDelayMs = DEFAULT_AUTOSAVE_DELAY_MS, cryptoRetryDelaysMs = DEFAULT_CRYPTO_RETRY_DELAYS_MS, }) {
|
|
101
|
-
const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, minimized } = session;
|
|
107
|
+
const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, minimized, quotePending, late } = session;
|
|
108
|
+
// What a reply worked out after this window opened (see `OpenComposeInput.pending`): better recipients, applied below
|
|
109
|
+
// only to fields still exactly as they opened - so these, not `initialTo`/`initialCc`, are what "untouched" is measured against.
|
|
110
|
+
const baselineTo = late?.to ?? initialTo;
|
|
111
|
+
const baselineCc = late?.cc ?? initialCc;
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
markComposePhase(id, "shell");
|
|
114
|
+
markComposePhase(id, "chunk");
|
|
115
|
+
}, []);
|
|
102
116
|
// The sending ("From") mailbox. A reply/forward session names the original message's mailbox; a fresh
|
|
103
117
|
// compose leaves it unset and defaults to the caller's own mailbox once `listMailboxes()` resolves.
|
|
104
118
|
// Everything mailbox-scoped below (Drafts folder, draft, signatures, crypto context) keys off this.
|
|
@@ -110,6 +124,8 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
110
124
|
const [hasUploads, setHasUploads] = useState(false);
|
|
111
125
|
const isMobile = useIsMobile();
|
|
112
126
|
const { requestUnlock } = useUnlockPrompt();
|
|
127
|
+
const { openCompose } = useCompose();
|
|
128
|
+
const keyEnv = useKeyEnvironment();
|
|
113
129
|
// Bumped after a successful on-demand unlock to force a re-render - `getUnlockedKeys()` below is a
|
|
114
130
|
// plain read from a module-level store, not React state, so nothing else would pick up the change.
|
|
115
131
|
const [, setUnlockRefresh] = useState(0);
|
|
@@ -368,26 +384,41 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
368
384
|
// non-blocking fetch in this codebase (e.g. `ConversationList`'s own child-message fetch) — no
|
|
369
385
|
// signature is a completely legitimate outcome, so this falls back to just the quoted content
|
|
370
386
|
// (if any) rather than surfacing an error over what's a cosmetic nicety.
|
|
387
|
+
//
|
|
388
|
+
// A reply or forward opens before its quoted original has been fetched (`quotePending`, see
|
|
389
|
+
// `OpenComposeInput.pending`). The body is seeded as soon as the signature is here, without the quote, so the editor is usable
|
|
390
|
+
// at once however slow the original is to come; the quote is added at the end of the editor's document when it arrives
|
|
391
|
+
// (`appendHtml` below) - the same place, under the signature, that a body seeded with it would have had it.
|
|
392
|
+
const [defaultSignature, setDefaultSignature] = useState();
|
|
371
393
|
useEffect(() => {
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
if (!mailboxUid || contentReady) {
|
|
394
|
+
// Fetched once, for whichever mailbox is the sender when compose opens - switching From later must not
|
|
395
|
+
// overwrite what the user has already written (the editor itself never re-syncs).
|
|
396
|
+
if (!mailboxUid || contentReady || defaultSignature) {
|
|
375
397
|
return;
|
|
376
398
|
}
|
|
377
399
|
listMailSignatures(mailboxUid)
|
|
378
400
|
.then((signatures) => {
|
|
379
401
|
const signature = signatures.find((s) => signatureContext === "new" ? s.isDefaultForNewMessages : s.isDefaultForReplyForward);
|
|
380
|
-
|
|
381
|
-
setHtml(seeded);
|
|
382
|
-
setSeededHtml(seeded);
|
|
383
|
-
})
|
|
384
|
-
.catch(() => {
|
|
385
|
-
const seeded = buildComposeBodyHtml(undefined, initialQuotedHtml);
|
|
386
|
-
setHtml(seeded);
|
|
387
|
-
setSeededHtml(seeded);
|
|
402
|
+
setDefaultSignature({ html: signature?.contentHtml });
|
|
388
403
|
})
|
|
389
|
-
.
|
|
390
|
-
}, [mailboxUid, signatureContext
|
|
404
|
+
.catch(() => setDefaultSignature({}));
|
|
405
|
+
}, [mailboxUid, signatureContext]);
|
|
406
|
+
const [seededWithoutQuote, setSeededWithoutQuote] = useState(false);
|
|
407
|
+
useEffect(() => {
|
|
408
|
+
if (contentReady || !defaultSignature) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const seeded = buildComposeBodyHtml(defaultSignature.html, quotePending ? undefined : initialQuotedHtml);
|
|
412
|
+
setHtml(seeded);
|
|
413
|
+
setSeededHtml(seeded);
|
|
414
|
+
setSeededWithoutQuote(!!quotePending);
|
|
415
|
+
setContentReady(true);
|
|
416
|
+
markComposePhase(id, "body");
|
|
417
|
+
}, [defaultSignature, quotePending, initialQuotedHtml]);
|
|
418
|
+
/** What the editor adds to the end of its document once the quote a reply was opened without has arrived. */
|
|
419
|
+
const lateQuoteHtml = seededWithoutQuote && !quotePending && initialQuotedHtml && defaultSignature
|
|
420
|
+
? buildComposeBodyHtml(defaultSignature.html, initialQuotedHtml).slice(buildComposeBodyHtml(defaultSignature.html) === "" ? "<p></p>".length : buildComposeBodyHtml(defaultSignature.html).length)
|
|
421
|
+
: undefined;
|
|
391
422
|
useEffect(() => {
|
|
392
423
|
if (!mailboxUid || !draftsFolderUid || draft) {
|
|
393
424
|
return;
|
|
@@ -405,6 +436,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
405
436
|
return;
|
|
406
437
|
}
|
|
407
438
|
setDraft(created);
|
|
439
|
+
markComposePhase(id, "draft");
|
|
408
440
|
// Only now that the replacement exists is it safe to discard the draft(s) a From switch
|
|
409
441
|
// superseded - if creating this one had failed, the earlier draft is still there.
|
|
410
442
|
for (const superseded of supersededDraftsRef.current.splice(0)) {
|
|
@@ -710,6 +742,9 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
710
742
|
};
|
|
711
743
|
const bodyContentType = 'text/html; charset="utf-8"';
|
|
712
744
|
const signing = canSign ? { certDer: keys.unlocked.signingCertDer, privateKey: keys.unlocked.signingPrivateKey } : undefined;
|
|
745
|
+
// The S/MIME code (PKI.js and the ASN.1/X.509 libraries, over half a megabyte) is only needed here, to sign or
|
|
746
|
+
// encrypt - a plain message never loads it, and it is kept out of the compose window's own chunk.
|
|
747
|
+
const { applyBaselineOuterHeaders, assembleOutboundMime, buildEncryptedMessage, buildSignedOnlyMessage } = await import("@rapidmx/react-shared/crypto/smimeMessage.js");
|
|
713
748
|
const outerHeaders = wantEncrypt ? applyBaselineOuterHeaders(protectedHeaders) : protectedHeaders;
|
|
714
749
|
const mimePart = wantEncrypt
|
|
715
750
|
? await buildEncryptedMessage(bodyContentType, html, protectedHeaders, outerHeaders, recipientCertDers, signing)
|
|
@@ -1019,7 +1054,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1019
1054
|
// Autosave. "User content" is anything beyond what compose opened with (prefilled recipients/subject,
|
|
1020
1055
|
// seeded signature/quote) - an untouched window has nothing worth saving or confirming a discard of.
|
|
1021
1056
|
const contentKey = JSON.stringify([to, cc, bcc, subject, html]);
|
|
1022
|
-
const baselineKey = JSON.stringify([
|
|
1057
|
+
const baselineKey = JSON.stringify([baselineTo ?? "", baselineCc ?? "", "", initialSubject ?? "", seededHtml ?? ""]);
|
|
1023
1058
|
const hasUserContent = contentKey !== baselineKey || hasUploads;
|
|
1024
1059
|
// A message headed for encryption is never autosaved: the draft would store its plaintext server-side,
|
|
1025
1060
|
// defeating end-to-end encryption. Until it's known not to be, it isn't either: while the mailbox (whose
|
|
@@ -1042,6 +1077,21 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1042
1077
|
(encryptionPossible && currentAddresses.some((address) => exhaustedLookups[address] && !recipientStatuses[address]));
|
|
1043
1078
|
const latestRef = useRef({ draft, to, cc, bcc, subject, html, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided });
|
|
1044
1079
|
latestRef.current = { draft, to, cc, bcc, subject, html, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided };
|
|
1080
|
+
// Recipients a reply could only work out after this window opened (Reply All: whoever the original's headers name)
|
|
1081
|
+
// replace the ones it opened with - but only in a field the user hasn't touched, never over what they typed.
|
|
1082
|
+
useEffect(() => {
|
|
1083
|
+
if (!late) {
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
const current = latestRef.current;
|
|
1087
|
+
if (late.to !== undefined && current.to === (initialTo ?? "")) {
|
|
1088
|
+
setTo(late.to);
|
|
1089
|
+
}
|
|
1090
|
+
if (late.cc !== undefined && current.cc === (initialCc ?? "")) {
|
|
1091
|
+
setCc(late.cc);
|
|
1092
|
+
setShowCcBcc((shown) => shown || late.cc !== "");
|
|
1093
|
+
}
|
|
1094
|
+
}, [late]);
|
|
1045
1095
|
useEffect(() => {
|
|
1046
1096
|
const due = !!draft &&
|
|
1047
1097
|
contentReady &&
|
|
@@ -1118,7 +1168,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1118
1168
|
// Where the caret starts, once per session: a reply/forward in the body (at its top), a new message in To - or,
|
|
1119
1169
|
// with To already filled in (e.g. Contacts' "Email"), in Subject. Minimizing unmounts the fields, so restoring a
|
|
1120
1170
|
// window must not move the caret there again.
|
|
1121
|
-
const isReplyOrForward = initialQuotedHtml !== undefined;
|
|
1171
|
+
const isReplyOrForward = initialQuotedHtml !== undefined || !!quotePending;
|
|
1122
1172
|
const fieldsFocusedRef = useRef(false);
|
|
1123
1173
|
const bodyFocusedRef = useRef(false);
|
|
1124
1174
|
const focusToOnMount = !isReplyOrForward && !initialTo && !fieldsFocusedRef.current;
|
|
@@ -1127,7 +1177,16 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1127
1177
|
// The editor serializes the seeded body its own way (see `RichTextEditor`'s `onInitialized`). While the body is
|
|
1128
1178
|
// still exactly what compose seeded, that serialization becomes the baseline - so an untouched reply or signature
|
|
1129
1179
|
// isn't mistaken for an edit (and autosaved) just because the editor was focused or clicked.
|
|
1180
|
+
/** The quote has been added: unless the reader has typed something already, that is still exactly what compose seeded. */
|
|
1181
|
+
function handleQuoteAppended(normalized) {
|
|
1182
|
+
const latest = latestRef.current;
|
|
1183
|
+
if (latest.html === latest.seededHtml) {
|
|
1184
|
+
setHtml(normalized);
|
|
1185
|
+
setSeededHtml(normalized);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1130
1188
|
function handleEditorInitialized(normalized) {
|
|
1189
|
+
markComposePhase(id, "editor");
|
|
1131
1190
|
const latest = latestRef.current;
|
|
1132
1191
|
if (latest.html === latest.seededHtml && normalized !== latest.html) {
|
|
1133
1192
|
setHtml(normalized);
|
|
@@ -1140,6 +1199,23 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1140
1199
|
bodyFocusedRef.current || (bodyFocusedRef.current = contentReady);
|
|
1141
1200
|
}
|
|
1142
1201
|
}, [minimized, contentReady]);
|
|
1202
|
+
// Keyboard shortcuts, active while the focus is inside this window (`container`; the scope attribute on its root is what tells the
|
|
1203
|
+
// dispatcher the focus is in a compose window at all). They do what the Send and Close buttons and autosave do, under the same conditions -
|
|
1204
|
+
// a shortcut pressed while its button would be disabled is consumed and does nothing.
|
|
1205
|
+
const canSend = !!draft && !sending && !closing && cryptoContextReady;
|
|
1206
|
+
useShortcut(SHORTCUTS.compose.send, () => void (canSend && submit(false)), { container: windowRef });
|
|
1207
|
+
useShortcut(SHORTCUTS.compose.saveDraft, () => {
|
|
1208
|
+
// Autosave's own conditions: never a draft that must not be stored as plaintext, and nothing while a send is on the wire.
|
|
1209
|
+
if (draft && contentReady && cryptoContextReady && !sending && !closing && !autosaveSuppressed && hasUserContent) {
|
|
1210
|
+
void saveDraftNow();
|
|
1211
|
+
}
|
|
1212
|
+
}, { container: windowRef });
|
|
1213
|
+
useShortcut(SHORTCUTS.compose.close, () => {
|
|
1214
|
+
if (!sending && !closing) {
|
|
1215
|
+
void handleClose();
|
|
1216
|
+
}
|
|
1217
|
+
}, { container: windowRef });
|
|
1218
|
+
useShortcut(SHORTCUTS.compose.create, () => openCompose({ mailboxUid }), { container: windowRef });
|
|
1143
1219
|
const title = subject.trim() || "New Message";
|
|
1144
1220
|
const titleId = `compose-title-${id}`;
|
|
1145
1221
|
const discardModal = (_jsxs(Modal, { open: closePrompt !== null, onClose: () => setClosePrompt(null), title: closePrompt?.title ?? DISCARD_TITLE, children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: closePrompt?.message }), _jsxs("div", { className: "flex gap-3", children: [closePrompt?.retry && (_jsx(Button, { type: "button", onClick: retryCryptoContext, className: "!w-auto", children: "Retry" })), closePrompt?.closeAnyway && (_jsx(Button, { type: "button", onClick: () => {
|
|
@@ -1149,12 +1225,12 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1149
1225
|
if (minimized) {
|
|
1150
1226
|
return (_jsxs(_Fragment, { children: [_jsx("div", { role: "dialog", "aria-label": title, className: "w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal", children: _jsxs("div", { className: "h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { className: "text-sm font-medium truncate", children: title }), sendError && _jsx("span", { className: "shrink-0 text-xs font-bold uppercase tracking-wide", children: "Not sent" }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Restore", onClick: onToggleMinimize, icon: HiOutlineArrowsPointingOut }), _jsx(HeaderButton, { label: "Discard draft", onClick: handleDiscard, icon: HiOutlineXMark, disabled: sending || closing })] })] }) }), discardModal] }));
|
|
1151
1227
|
}
|
|
1152
|
-
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: windowRef, role: "dialog", "aria-labelledby": titleId, style: !isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined, className: [
|
|
1228
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: windowRef, role: "dialog", "data-shortcut-scope": "compose", "aria-labelledby": titleId, style: !isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined, className: [
|
|
1153
1229
|
"relative shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
|
|
1154
1230
|
isMobile
|
|
1155
1231
|
? "fixed inset-0 w-full h-full rounded-none border-0"
|
|
1156
1232
|
: ["border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
|
|
1157
|
-
].join(" "), children: [!isMobile && (_jsxs(_Fragment, { children: [_jsx("div", { onPointerDown: handleResizeStart("top"), "aria-hidden": "true", className: "absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("left"), "aria-hidden": "true", className: "absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("corner"), role: "separator", "aria-label": "Resize", className: "absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30" })] })), _jsxs("div", { className: "h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { id: titleId, className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus }), !isMobile && (_jsx(HeaderButton, { label: expanded ? "Collapse" : "Expand", onClick: toggleExpanded, icon: expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut })), _jsx(HeaderButton, { label: "Close", onClick: () => void handleClose(), icon: HiOutlineXMark, disabled: sending || closing })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError || discardError) && (_jsxs("div", { className: "px-3 pt-2", children: [folderError && _jsx(Alert, { children: folderError }), draftError && _jsx(Alert, { children: draftError }), sendError && _jsx(SendFailureAlert, { message: sendError, lines: sendDetails }), attachError && _jsx(Alert, { children: attachError }), discardError && _jsx(Alert, { children: discardError })] })), cryptoCheckUnavailable && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: CRYPTO_UNAVAILABLE_MESSAGE }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: retryCryptoContext, children: "Retry" })] }) })), encryptionBlocked && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsxs("p", { className: "mb-2", children: ["This message can\u2019t be encrypted for everyone: ", encryptionBlocked.map((r) => r.address).join(", "), encryptionBlocked[0]?.prohibitedReason ? ` — ${encryptionBlocked[0].prohibitedReason}` : " has no encryption key on file", ". Remove ", encryptionBlocked.length > 1 ? "these recipients" : "this recipient", " from To/Cc/Bcc, or send the whole message in plaintext."] }), _jsx(Button, { type: "button", disabled: sending, onClick: () => void submit(true, blockedScheduleIso), children: "Send without encryption" })] }) })), securityBlock && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: securityBlock.message }), _jsx(Button, { type: "button", disabled: sending, onClick: () => void submit(true, blockedScheduleIso), children: securityBlock.overrideLabel })] }) })), fromOptions.length > 1 && (_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-from-${id}`, className: "text-xs text-text-muted shrink-0", children: "From" }), _jsx("select", { id: `compose-from-${id}`, className: `${FIELD_INPUT} disabled:opacity-55`, value: mailboxUid ?? "", disabled: hasUploads || sending, title: hasUploads ? "The sender can't be changed after adding attachments or images." : undefined, onChange: (e) => handleFromChange(e.target.value), children: fromOptions.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName ? `${mb.displayName} <${mb.primarySmtpAddress}>` : mb.primarySmtpAddress, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-to-${id}`, className: "text-xs text-text-muted shrink-0", children: "To" }), _jsx(RecipientInput, { id: `compose-to-${id}`, label: "To", value: to, mailboxUid: mailboxUid, onChange: setTo, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)), autoFocus: focusToOnMount }), !showCcBcc && (_jsx("button", { type: "button", onClick: () => setShowCcBcc(true), className: "text-xs text-text-muted hover:text-text shrink-0", children: "Cc Bcc" }))] }), showCcBcc && (_jsxs(_Fragment, { children: [_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-cc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Cc" }), _jsx(RecipientInput, { id: `compose-cc-${id}`, label: "Cc", value: cc, mailboxUid: mailboxUid, onChange: setCc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] }), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-bcc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Bcc" }), _jsx(RecipientInput, { id: `compose-bcc-${id}`, label: "Bcc", value: bcc, mailboxUid: mailboxUid, onChange: setBcc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] })] })), (() => {
|
|
1233
|
+
].join(" "), children: [!isMobile && (_jsxs(_Fragment, { children: [_jsx("div", { onPointerDown: handleResizeStart("top"), "aria-hidden": "true", className: "absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("left"), "aria-hidden": "true", className: "absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("corner"), role: "separator", "aria-label": "Resize", className: "absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30" })] })), _jsxs("div", { className: "h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { id: titleId, className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus }), !isMobile && (_jsx(HeaderButton, { label: expanded ? "Collapse" : "Expand", onClick: toggleExpanded, icon: expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut })), _jsx(HeaderButton, { label: "Close", onClick: () => void handleClose(), icon: HiOutlineXMark, disabled: sending || closing, shortcut: SHORTCUTS.compose.close })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError || discardError) && (_jsxs("div", { className: "px-3 pt-2 shrink-0 max-h-[45%] overflow-y-auto", children: [folderError && _jsx(Alert, { children: folderError }), draftError && _jsx(Alert, { children: draftError }), sendError && _jsx(SendFailureAlert, { message: sendError, lines: sendDetails }), attachError && _jsx(Alert, { children: attachError }), discardError && _jsx(Alert, { children: discardError })] })), cryptoCheckUnavailable && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: CRYPTO_UNAVAILABLE_MESSAGE }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: retryCryptoContext, children: "Retry" })] }) })), encryptionBlocked && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsxs("p", { className: "mb-2", children: ["This message can\u2019t be encrypted for everyone: ", encryptionBlocked.map((r) => r.address).join(", "), encryptionBlocked[0]?.prohibitedReason ? ` — ${encryptionBlocked[0].prohibitedReason}` : " has no encryption key on file", ". Remove ", encryptionBlocked.length > 1 ? "these recipients" : "this recipient", " from To/Cc/Bcc, or send the whole message in plaintext."] }), _jsx(Button, { type: "button", disabled: sending, onClick: () => void submit(true, blockedScheduleIso), children: "Send without encryption" })] }) })), securityBlock && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: securityBlock.message }), _jsx(Button, { type: "button", disabled: sending, onClick: () => void submit(true, blockedScheduleIso), children: securityBlock.overrideLabel })] }) })), fromOptions.length > 1 && (_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-from-${id}`, className: "text-xs text-text-muted shrink-0", children: "From" }), _jsx("select", { id: `compose-from-${id}`, className: `${FIELD_INPUT} disabled:opacity-55`, value: mailboxUid ?? "", disabled: hasUploads || sending, title: hasUploads ? "The sender can't be changed after adding attachments or images." : undefined, onChange: (e) => handleFromChange(e.target.value), children: fromOptions.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName ? `${mb.displayName} <${mb.primarySmtpAddress}>` : mb.primarySmtpAddress, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-to-${id}`, className: "text-xs text-text-muted shrink-0", children: "To" }), _jsx(RecipientInput, { id: `compose-to-${id}`, label: "To", value: to, mailboxUid: mailboxUid, onChange: setTo, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)), autoFocus: focusToOnMount }), !showCcBcc && (_jsx("button", { type: "button", onClick: () => setShowCcBcc(true), className: "text-xs text-text-muted hover:text-text shrink-0", children: "Cc Bcc" }))] }), showCcBcc && (_jsxs(_Fragment, { children: [_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-cc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Cc" }), _jsx(RecipientInput, { id: `compose-cc-${id}`, label: "Cc", value: cc, mailboxUid: mailboxUid, onChange: setCc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] }), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-bcc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Bcc" }), _jsx(RecipientInput, { id: `compose-bcc-${id}`, label: "Bcc", value: bcc, mailboxUid: mailboxUid, onChange: setBcc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] })] })), (() => {
|
|
1158
1234
|
const knownRecipients = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]
|
|
1159
1235
|
.map((r) => recipientStatuses[r.address])
|
|
1160
1236
|
.filter((status) => !!status);
|
|
@@ -1162,5 +1238,5 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1162
1238
|
return null;
|
|
1163
1239
|
}
|
|
1164
1240
|
return (_jsx("ul", { className: "flex flex-wrap gap-1.5 px-3 pt-1.5", "aria-label": "Recipient encryption availability", children: knownRecipients.map((status) => (_jsxs("li", { className: `text-xs font-medium py-0.5 px-2 rounded-pill ${status.canEncrypt ? "bg-primary/10 text-primary-dark" : "bg-surface-alt text-text-muted"}`, children: [status.address, " ", status.canEncrypt ? "supports encryption" : "no encryption key found"] }, status.address))) }));
|
|
1165
|
-
})(), _jsx("div", { className: FIELD_ROW, children: _jsx("input", { "aria-label": "Subject", type: "text", placeholder: "Subject", className: FIELD_INPUT, value: subject, autoFocus: focusSubjectOnMount, onChange: (e) => setSubject(e.target.value) }) }), _jsx("div", { className: "flex-1 min-h-0 p-2", children: contentReady && (_jsx(RichTextEditor, { value: html, onChange: setHtml, fill: true, onUploadImage: handleUploadImage, autoFocusStart: focusBodyOnMount, onInitialized: handleEditorInitialized })) }), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 px-3 pb-2", children: attachments.map((attachment) => (_jsx("li", { className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted", children: attachment.filename }, attachment.uid))) })), _jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: requestReceipt, onChange: (e) => setRequestReceipt(e.target.checked) }), "Request a read receipt"] }), needsUnlockForCrypto && (_jsx("div", { className: "px-3 pb-1", children: _jsxs("button", { type: "button", onClick: handleUnlockForCrypto, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to sign or encrypt this message"] }) })), unlockedKeys?.signingPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: signEnabled, onChange: (e) => setSignEnabled(e.target.checked) }), "Digitally sign this message"] })), unlockedKeys?.encryptionPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: encryptRequested, onChange: (e) => setEncryptRequested(e.target.checked) }), "Encrypt this message"] })), _jsxs("div", { className: "shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border", children: [_jsxs("div", { className: "flex items-center rounded-pill bg-primary text-white overflow-hidden", children: [_jsx("button", { type: "button", onClick: () => void submit(false), disabled: !draft || sending || closing || !cryptoContextReady, className: "py-1.5 pl-5 pr-3 font-semibold text-sm hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: sending ? "Sending…" : "Send" }), _jsx("button", { ref: scheduleButtonRef, type: "button", "aria-label": "Send later", "aria-haspopup": "true", "aria-expanded": schedulePickerOpen, disabled: !draft || sending || closing || !cryptoContextReady, onClick: () => setSchedulePickerOpen((o) => !o), className: "py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: _jsx(HiOutlineChevronDown, { size: 14 }) })] }), schedulePickerOpen && (_jsx(ScheduleSendPicker, { anchorRef: scheduleButtonRef, onClose: () => setSchedulePickerOpen(false), onSchedule: handleScheduleSend })), _jsxs("label", { "aria-label": "Attach files", title: "Attach files", className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed", children: [_jsx(HiOutlinePaperClip, { size: 18 }), _jsx("input", { type: "file", multiple: true, disabled: !draft, onChange: handleFilesSelected, className: "sr-only" })] }), _jsx("span", { role: "status", "aria-live": "polite", className: "ml-auto text-xs text-text-muted", children: SAVE_STATUS_LABEL[saveStatus] }), _jsx("button", { type: "button", "aria-label": "Discard draft", title: "Discard draft", onClick: handleDiscard, disabled: sending || closing, className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text disabled:opacity-40 disabled:cursor-not-allowed", children: _jsx(HiOutlineTrash, { size: 18 }) })] })] })] }), discardModal] }));
|
|
1241
|
+
})(), _jsx("div", { className: FIELD_ROW, children: _jsx("input", { "aria-label": "Subject", type: "text", placeholder: "Subject", className: FIELD_INPUT, value: subject, autoFocus: focusSubjectOnMount, onChange: (e) => setSubject(e.target.value) }) }), _jsx("div", { className: "flex-1 min-h-0 p-2", children: contentReady && (_jsx(RichTextEditor, { appendHtml: lateQuoteHtml, onAppended: handleQuoteAppended, value: html, onChange: setHtml, fill: true, onUploadImage: handleUploadImage, autoFocusStart: focusBodyOnMount, onInitialized: handleEditorInitialized })) }), quotePending && (_jsx("p", { role: "status", className: "px-3 pb-1 text-xs text-text-muted", children: "Loading the original message\u2026" })), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 px-3 pb-2", children: attachments.map((attachment) => (_jsx("li", { className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted", children: attachment.filename }, attachment.uid))) })), _jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: requestReceipt, onChange: (e) => setRequestReceipt(e.target.checked) }), "Request a read receipt"] }), needsUnlockForCrypto && (_jsx("div", { className: "px-3 pb-1", children: _jsxs("button", { type: "button", onClick: handleUnlockForCrypto, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to sign or encrypt this message"] }) })), unlockedKeys?.signingPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: signEnabled, onChange: (e) => setSignEnabled(e.target.checked) }), "Digitally sign this message"] })), unlockedKeys?.encryptionPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: encryptRequested, onChange: (e) => setEncryptRequested(e.target.checked) }), "Encrypt this message"] })), _jsxs("div", { className: "shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border", children: [_jsxs("div", { className: "flex items-center rounded-pill bg-primary text-white overflow-hidden", children: [_jsx("button", { type: "button", onClick: () => void submit(false), title: withHint("Send", SHORTCUTS.compose.send, keyEnv), "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.compose.send, keyEnv), disabled: !draft || sending || closing || !cryptoContextReady, className: "py-1.5 pl-5 pr-3 font-semibold text-sm hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: sending ? "Sending…" : "Send" }), _jsx("button", { ref: scheduleButtonRef, type: "button", "aria-label": "Send later", "aria-haspopup": "true", "aria-expanded": schedulePickerOpen, disabled: !draft || sending || closing || !cryptoContextReady, onClick: () => setSchedulePickerOpen((o) => !o), className: "py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: _jsx(HiOutlineChevronDown, { size: 14 }) })] }), schedulePickerOpen && (_jsx(ScheduleSendPicker, { anchorRef: scheduleButtonRef, onClose: () => setSchedulePickerOpen(false), onSchedule: handleScheduleSend })), _jsxs("label", { "aria-label": "Attach files", title: "Attach files", className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed", children: [_jsx(HiOutlinePaperClip, { size: 18 }), _jsx("input", { type: "file", multiple: true, disabled: !draft, onChange: handleFilesSelected, className: "sr-only" })] }), _jsx("span", { role: "status", "aria-live": "polite", className: "ml-auto text-xs text-text-muted", children: SAVE_STATUS_LABEL[saveStatus] }), _jsx("button", { type: "button", "aria-label": "Discard draft", title: "Discard draft", onClick: handleDiscard, disabled: sending || closing, className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text disabled:opacity-40 disabled:cursor-not-allowed", children: _jsx(HiOutlineTrash, { size: 18 }) })] })] })] }), discardModal] }));
|
|
1166
1242
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { ComposeSession } from "./ComposeContext.js";
|
|
3
|
+
export interface ComposeWindowPlaceholderProps {
|
|
4
|
+
session: ComposeSession;
|
|
5
|
+
/** The compose window's code couldn't be downloaded. */
|
|
6
|
+
failed: boolean;
|
|
7
|
+
onRetry: () => void;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
onToggleMinimize: () => void;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* What stands in for the compose window in the moment between clicking Compose/Reply/Forward and the window's code being
|
|
13
|
+
* loaded (already there when it was prefetched, so usually not seen at all): the window's own frame at its own size and
|
|
14
|
+
* place, its title and the recipients and subject the window will open with, all of which are known without the network -
|
|
15
|
+
* so the click is answered on the same frame. It is deliberately tiny and lives in the page's own chunk.
|
|
16
|
+
*
|
|
17
|
+
* Nothing here can be typed into yet, and there is no draft to save or discard, so Close just closes.
|
|
18
|
+
*/
|
|
19
|
+
export default function ComposeWindowPlaceholder({ session, failed, onRetry, onClose, onToggleMinimize }: ComposeWindowPlaceholderProps): React.JSX.Element;
|