@rapidmx/web-client 0.8.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/elevation.ts +61 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +73 -7
- package/apps/shared/components/admin/settings/BrandingForm.tsx +2 -1
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +87 -24
- 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/BrandingChrome.tsx +3 -2
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
- package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -126
- package/apps/shared/components/layout/UserMenu.tsx +303 -150
- package/apps/shared/components/mail/ConversationList.tsx +269 -253
- package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
- package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
- package/apps/shared/components/mail/MailAddress.tsx +88 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
- package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1383
- 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 -1700
- 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/SendFailureAlert.tsx +48 -0
- 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 -445
- 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/mergeFirstPage.ts +47 -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 -0
- 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 -1854
- 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/elevation.d.ts +24 -0
- package/dist/apps/shared/components/admin/elevation.js +56 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +12 -4
- package/dist/apps/shared/components/admin/layout/AdminShell.js +49 -6
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +1 -1
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +31 -13
- 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/BrandingChrome.d.ts +3 -2
- package/dist/apps/shared/components/layout/BrandingChrome.js +3 -2
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
- package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +5 -2
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +45 -8
- package/dist/apps/shared/components/layout/UserMenu.d.ts +28 -9
- package/dist/apps/shared/components/layout/UserMenu.js +87 -14
- package/dist/apps/shared/components/mail/ConversationList.js +9 -13
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +53 -28
- 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/MailAddress.d.ts +27 -0
- package/dist/apps/shared/components/mail/MailAddress.js +39 -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 +87 -36
- 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 +122 -27
- 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/SendFailureAlert.d.ts +14 -0
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +11 -0
- 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 +19 -4
- package/dist/apps/shared/components/mail/layout/MailShell.js +85 -85
- 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/mergeFirstPage.d.ts +23 -0
- package/dist/apps/shared/mail/mergeFirstPage.js +30 -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 +55 -0
- package/dist/apps/shared/mail/useMailLiveUpdates.js +182 -0
- 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 +361 -34
- 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
|
@@ -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
|
}
|
|
@@ -8,6 +8,7 @@ import { HiOutlineArrowsPointingIn, HiOutlineArrowsPointingOut, HiOutlineChevron
|
|
|
8
8
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
9
9
|
import { assembleDraft, assembleDraftRaw, attachmentContentUrl, createDraft, deleteMessage, getMailbox, getMessage, listFolders, listMailboxes, sendMessage, setMessageRequestReceipt, uploadAttachment, } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
10
10
|
import { listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
|
|
11
|
+
import { describeSendFailure } from "@rapidmx/react-shared/mail/sendFailure.js";
|
|
11
12
|
import { buildComposeBodyHtml } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
|
|
12
13
|
import { peekMailboxWritability, useMailboxWritability } from "../writableMailboxes.js";
|
|
13
14
|
import { decideMessageEncryption, resolveRecipientEncryption } from "@rapidmx/react-shared/crypto/composeSecurity.js";
|
|
@@ -15,13 +16,19 @@ import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/cryp
|
|
|
15
16
|
import { findActivePublicKey, getEncryptionPolicy, lookupKeys } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
16
17
|
import { useUnlockPrompt } from "../../layout/UnlockPromptProvider.js";
|
|
17
18
|
import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
|
|
18
|
-
import { applyBaselineOuterHeaders, assembleOutboundMime, buildEncryptedMessage, buildSignedOnlyMessage } from "@rapidmx/react-shared/crypto/smimeMessage.js";
|
|
19
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";
|
|
20
25
|
import { isSigningOut, registerComposeFlush } from "./composeFlushRegistry.js";
|
|
26
|
+
import { markComposePhase } from "./composePerf.js";
|
|
21
27
|
import RecipientInput from "./RecipientInput.js";
|
|
22
28
|
import { parseRecipientList } from "./recipients.js";
|
|
23
29
|
import RichTextEditor from "./RichTextEditor.js";
|
|
24
30
|
import ScheduleSendPicker from "./ScheduleSendPicker.js";
|
|
31
|
+
import SendFailureAlert from "./SendFailureAlert.js";
|
|
25
32
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
26
33
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
27
34
|
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
@@ -59,6 +66,10 @@ const ENCRYPTED_CLOSE_MESSAGE = "Encrypted messages aren't saved as drafts, so c
|
|
|
59
66
|
const CHECKING_CLOSE_MESSAGE = "This message may be encrypted, and that's still being checked, so it can't be saved as a draft yet. Keep editing and close again in a moment, or discard it.";
|
|
60
67
|
const CRYPTO_UNAVAILABLE_MESSAGE = "Your encryption settings couldn't be checked, so this draft isn't being saved - it might be a message that must be encrypted.";
|
|
61
68
|
const NO_RECIPIENTS_CLOSE_MESSAGE = "This message may be encrypted once its recipients are known, so it isn't saved as a draft until you add them. Add a recipient and close again, or discard it.";
|
|
69
|
+
const SEND_FAILED_TITLE = "This message wasn't sent";
|
|
70
|
+
function sendFailedCloseMessage(reason) {
|
|
71
|
+
return `Sending it failed (${reason.replace(/\.$/, "")}). Closing keeps it as a draft, unsent. Keep editing to try again, or discard it.`;
|
|
72
|
+
}
|
|
62
73
|
const FIELD_ROW = "flex items-center gap-2 px-3 py-1.5 border-b border-border";
|
|
63
74
|
const FIELD_INPUT = "flex-1 min-w-0 text-sm bg-transparent outline-none";
|
|
64
75
|
const MIN_WIDTH = 320;
|
|
@@ -74,8 +85,9 @@ const VIEWPORT_MARGIN = 24;
|
|
|
74
85
|
function parseAddresses(value) {
|
|
75
86
|
return parseRecipientList(value);
|
|
76
87
|
}
|
|
77
|
-
function HeaderButton({ label, onClick, icon: Icon, disabled, }) {
|
|
78
|
-
|
|
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 }) }));
|
|
79
91
|
}
|
|
80
92
|
/**
|
|
81
93
|
* A floating, Gmail-style compose window — overlays whatever app is currently showing instead of
|
|
@@ -92,7 +104,15 @@ function HeaderButton({ label, onClick, icon: Icon, disabled, }) {
|
|
|
92
104
|
* once.
|
|
93
105
|
*/
|
|
94
106
|
export default function ComposeWindow({ session, onClose, onToggleMinimize, userUid, trusted, autosaveDelayMs = DEFAULT_AUTOSAVE_DELAY_MS, cryptoRetryDelaysMs = DEFAULT_CRYPTO_RETRY_DELAYS_MS, }) {
|
|
95
|
-
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
|
+
}, []);
|
|
96
116
|
// The sending ("From") mailbox. A reply/forward session names the original message's mailbox; a fresh
|
|
97
117
|
// compose leaves it unset and defaults to the caller's own mailbox once `listMailboxes()` resolves.
|
|
98
118
|
// Everything mailbox-scoped below (Drafts folder, draft, signatures, crypto context) keys off this.
|
|
@@ -104,6 +124,8 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
104
124
|
const [hasUploads, setHasUploads] = useState(false);
|
|
105
125
|
const isMobile = useIsMobile();
|
|
106
126
|
const { requestUnlock } = useUnlockPrompt();
|
|
127
|
+
const { openCompose } = useCompose();
|
|
128
|
+
const keyEnv = useKeyEnvironment();
|
|
107
129
|
// Bumped after a successful on-demand unlock to force a re-render - `getUnlockedKeys()` below is a
|
|
108
130
|
// plain read from a module-level store, not React state, so nothing else would pick up the change.
|
|
109
131
|
const [, setUnlockRefresh] = useState(0);
|
|
@@ -126,6 +148,8 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
126
148
|
const [attachments, setAttachments] = useState([]);
|
|
127
149
|
const [attachError, setAttachError] = useState(null);
|
|
128
150
|
const [sendError, setSendError] = useState(null);
|
|
151
|
+
// The technical facts behind `sendError` (per-recipient SMTP results, a transport error), when the server gave any.
|
|
152
|
+
const [sendDetails, setSendDetails] = useState([]);
|
|
129
153
|
const [sending, setSending] = useState(false);
|
|
130
154
|
const [requestReceipt, setRequestReceipt] = useState(false);
|
|
131
155
|
const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
|
|
@@ -360,26 +384,41 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
360
384
|
// non-blocking fetch in this codebase (e.g. `ConversationList`'s own child-message fetch) — no
|
|
361
385
|
// signature is a completely legitimate outcome, so this falls back to just the quoted content
|
|
362
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();
|
|
363
393
|
useEffect(() => {
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
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) {
|
|
367
397
|
return;
|
|
368
398
|
}
|
|
369
399
|
listMailSignatures(mailboxUid)
|
|
370
400
|
.then((signatures) => {
|
|
371
401
|
const signature = signatures.find((s) => signatureContext === "new" ? s.isDefaultForNewMessages : s.isDefaultForReplyForward);
|
|
372
|
-
|
|
373
|
-
setHtml(seeded);
|
|
374
|
-
setSeededHtml(seeded);
|
|
402
|
+
setDefaultSignature({ html: signature?.contentHtml });
|
|
375
403
|
})
|
|
376
|
-
.catch(() => {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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;
|
|
383
422
|
useEffect(() => {
|
|
384
423
|
if (!mailboxUid || !draftsFolderUid || draft) {
|
|
385
424
|
return;
|
|
@@ -397,6 +436,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
397
436
|
return;
|
|
398
437
|
}
|
|
399
438
|
setDraft(created);
|
|
439
|
+
markComposePhase(id, "draft");
|
|
400
440
|
// Only now that the replacement exists is it safe to discard the draft(s) a From switch
|
|
401
441
|
// superseded - if creating this one had failed, the earlier draft is still there.
|
|
402
442
|
for (const superseded of supersededDraftsRef.current.splice(0)) {
|
|
@@ -702,6 +742,9 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
702
742
|
};
|
|
703
743
|
const bodyContentType = 'text/html; charset="utf-8"';
|
|
704
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");
|
|
705
748
|
const outerHeaders = wantEncrypt ? applyBaselineOuterHeaders(protectedHeaders) : protectedHeaders;
|
|
706
749
|
const mimePart = wantEncrypt
|
|
707
750
|
? await buildEncryptedMessage(bodyContentType, html, protectedHeaders, outerHeaders, recipientCertDers, signing)
|
|
@@ -736,6 +779,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
736
779
|
setSending(true);
|
|
737
780
|
sendingRef.current = true;
|
|
738
781
|
setSendError(null);
|
|
782
|
+
setSendDetails([]);
|
|
739
783
|
setSecurityBlock(null);
|
|
740
784
|
setEncryptionBlocked(null);
|
|
741
785
|
pendingSaveRef.current = false;
|
|
@@ -756,8 +800,10 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
756
800
|
onClose();
|
|
757
801
|
}
|
|
758
802
|
catch (err) {
|
|
759
|
-
|
|
760
|
-
|
|
803
|
+
// The window stays open with the draft exactly as it was; nothing here closes, discards or saves over it.
|
|
804
|
+
const failure = describeSendFailure(err, scheduledSendTimeIso ? "Could not schedule this message." : "Could not send this message.");
|
|
805
|
+
setSendError(failure.message);
|
|
806
|
+
setSendDetails(failure.lines);
|
|
761
807
|
}
|
|
762
808
|
finally {
|
|
763
809
|
sendingRef.current = false;
|
|
@@ -922,11 +968,16 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
922
968
|
* the same confirmation as a discard. A failed save keeps the window open with Discard / Keep editing.
|
|
923
969
|
* Close and Discard are both disabled while a send is in progress: deleting the draft, or saving a
|
|
924
970
|
* plaintext copy over the message the send just assembled, mid-send would lose or leak it. */
|
|
925
|
-
async function handleClose() {
|
|
971
|
+
async function handleClose(sendFailureAcknowledged = false) {
|
|
926
972
|
if (!hasUserContent) {
|
|
927
973
|
void discardNow();
|
|
928
974
|
return;
|
|
929
975
|
}
|
|
976
|
+
if (sendError && !sendFailureAcknowledged) {
|
|
977
|
+
// Closing would keep the draft and say nothing, which reads as if it had been sent.
|
|
978
|
+
setClosePrompt({ title: SEND_FAILED_TITLE, message: sendFailedCloseMessage(sendError), closeAnyway: true });
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
930
981
|
if (encryptionDecided) {
|
|
931
982
|
setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
|
|
932
983
|
return;
|
|
@@ -1003,7 +1054,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1003
1054
|
// Autosave. "User content" is anything beyond what compose opened with (prefilled recipients/subject,
|
|
1004
1055
|
// seeded signature/quote) - an untouched window has nothing worth saving or confirming a discard of.
|
|
1005
1056
|
const contentKey = JSON.stringify([to, cc, bcc, subject, html]);
|
|
1006
|
-
const baselineKey = JSON.stringify([
|
|
1057
|
+
const baselineKey = JSON.stringify([baselineTo ?? "", baselineCc ?? "", "", initialSubject ?? "", seededHtml ?? ""]);
|
|
1007
1058
|
const hasUserContent = contentKey !== baselineKey || hasUploads;
|
|
1008
1059
|
// A message headed for encryption is never autosaved: the draft would store its plaintext server-side,
|
|
1009
1060
|
// defeating end-to-end encryption. Until it's known not to be, it isn't either: while the mailbox (whose
|
|
@@ -1026,6 +1077,21 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1026
1077
|
(encryptionPossible && currentAddresses.some((address) => exhaustedLookups[address] && !recipientStatuses[address]));
|
|
1027
1078
|
const latestRef = useRef({ draft, to, cc, bcc, subject, html, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided });
|
|
1028
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]);
|
|
1029
1095
|
useEffect(() => {
|
|
1030
1096
|
const due = !!draft &&
|
|
1031
1097
|
contentReady &&
|
|
@@ -1102,7 +1168,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1102
1168
|
// Where the caret starts, once per session: a reply/forward in the body (at its top), a new message in To - or,
|
|
1103
1169
|
// with To already filled in (e.g. Contacts' "Email"), in Subject. Minimizing unmounts the fields, so restoring a
|
|
1104
1170
|
// window must not move the caret there again.
|
|
1105
|
-
const isReplyOrForward = initialQuotedHtml !== undefined;
|
|
1171
|
+
const isReplyOrForward = initialQuotedHtml !== undefined || !!quotePending;
|
|
1106
1172
|
const fieldsFocusedRef = useRef(false);
|
|
1107
1173
|
const bodyFocusedRef = useRef(false);
|
|
1108
1174
|
const focusToOnMount = !isReplyOrForward && !initialTo && !fieldsFocusedRef.current;
|
|
@@ -1111,7 +1177,16 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1111
1177
|
// The editor serializes the seeded body its own way (see `RichTextEditor`'s `onInitialized`). While the body is
|
|
1112
1178
|
// still exactly what compose seeded, that serialization becomes the baseline - so an untouched reply or signature
|
|
1113
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
|
+
}
|
|
1114
1188
|
function handleEditorInitialized(normalized) {
|
|
1189
|
+
markComposePhase(id, "editor");
|
|
1115
1190
|
const latest = latestRef.current;
|
|
1116
1191
|
if (latest.html === latest.seededHtml && normalized !== latest.html) {
|
|
1117
1192
|
setHtml(normalized);
|
|
@@ -1124,18 +1199,38 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1124
1199
|
bodyFocusedRef.current || (bodyFocusedRef.current = contentReady);
|
|
1125
1200
|
}
|
|
1126
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 });
|
|
1127
1219
|
const title = subject.trim() || "New Message";
|
|
1128
1220
|
const titleId = `compose-title-${id}`;
|
|
1129
|
-
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" })),
|
|
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: () => {
|
|
1222
|
+
setClosePrompt(null);
|
|
1223
|
+
void handleClose(true);
|
|
1224
|
+
}, disabled: sending || closing, className: "!w-auto", children: "Close, keep draft" })), _jsx(Button, { type: "button", onClick: () => void discardNow(), disabled: sending || closing, className: "!w-auto", children: "Discard" }), _jsx(Button, { type: "button", variant: "secondary", onClick: () => setClosePrompt(null), className: "!w-auto", children: "Keep editing" })] })] }));
|
|
1130
1225
|
if (minimized) {
|
|
1131
|
-
return (_jsxs(_Fragment, { children: [_jsx("div", { role: "dialog", "aria-label": title, className: "w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal", children: _jsxs("div", { className: "h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Restore", onClick: onToggleMinimize, icon: HiOutlineArrowsPointingOut }), _jsx(HeaderButton, { label: "Discard draft", onClick: handleDiscard, icon: HiOutlineXMark, disabled: sending || closing })] })] }) }), discardModal] }));
|
|
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] }));
|
|
1132
1227
|
}
|
|
1133
|
-
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: [
|
|
1134
1229
|
"relative shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
|
|
1135
1230
|
isMobile
|
|
1136
1231
|
? "fixed inset-0 w-full h-full rounded-none border-0"
|
|
1137
1232
|
: ["border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
|
|
1138
|
-
].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(
|
|
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)) })] })] })), (() => {
|
|
1139
1234
|
const knownRecipients = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]
|
|
1140
1235
|
.map((r) => recipientStatuses[r.address])
|
|
1141
1236
|
.filter((status) => !!status);
|
|
@@ -1143,5 +1238,5 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1143
1238
|
return null;
|
|
1144
1239
|
}
|
|
1145
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))) }));
|
|
1146
|
-
})(), _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] }));
|
|
1147
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;
|