@rapidmx/web-client 0.6.0 → 0.7.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/apps/shared/components/admin/settings/BrandingForm.tsx +398 -385
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -112
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +159 -152
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1288 -1235
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +9 -1
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -290
- package/apps/shared/components/admin/setup/SetupWizard.tsx +443 -416
- package/apps/shared/components/layout/AppShell.tsx +288 -288
- package/apps/shared/components/mail/ConversationList.tsx +225 -33
- package/apps/shared/components/mail/ConversationThreadPane.tsx +434 -216
- package/apps/shared/components/mail/MailListToolbar.tsx +265 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +222 -0
- package/apps/shared/components/mail/MenuButton.tsx +384 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +280 -215
- package/apps/shared/components/mail/MoveToFolderDialog.tsx +263 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +29 -2
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1700 -1652
- package/apps/shared/components/mail/compose/RecipientInput.tsx +356 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +38 -3
- package/apps/shared/components/mail/compose/quotedBody.ts +100 -0
- package/apps/shared/components/mail/compose/recipients.ts +104 -0
- package/apps/shared/components/mail/labelMenu.tsx +278 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +445 -420
- package/apps/shared/components/mail/listPreferences.ts +228 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +0 -1
- package/apps/shared/styles/app.css +28 -5
- package/apps/www/index.tsx +1854 -1314
- package/apps/www/messages/[uid].tsx +5 -4
- package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +7 -3
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +6 -5
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.d.ts +4 -1
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +2 -2
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +4 -1
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +2 -2
- package/dist/apps/shared/components/admin/settings/PluginsManager.d.ts +8 -1
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +33 -14
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.d.ts +4 -1
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +2 -2
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +1 -1
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +14 -10
- package/dist/apps/shared/components/mail/ConversationList.d.ts +42 -6
- package/dist/apps/shared/components/mail/ConversationList.js +84 -10
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +52 -13
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +217 -60
- package/dist/apps/shared/components/mail/MailListToolbar.d.ts +55 -0
- package/dist/apps/shared/components/mail/MailListToolbar.js +149 -0
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +59 -0
- package/dist/apps/shared/components/mail/MailSelectionBar.js +48 -0
- package/dist/apps/shared/components/mail/MenuButton.d.ts +71 -0
- package/dist/apps/shared/components/mail/MenuButton.js +211 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +27 -19
- package/dist/apps/shared/components/mail/MessageDetailPane.js +126 -129
- package/dist/apps/shared/components/mail/MoveToFolderDialog.d.ts +57 -0
- package/dist/apps/shared/components/mail/MoveToFolderDialog.js +138 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +16 -1
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +3 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +52 -20
- package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +36 -0
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +203 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +10 -1
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +21 -3
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +37 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.js +85 -0
- package/dist/apps/shared/components/mail/compose/recipients.d.ts +24 -0
- package/dist/apps/shared/components/mail/compose/recipients.js +87 -0
- package/dist/apps/shared/components/mail/labelMenu.d.ts +97 -0
- package/dist/apps/shared/components/mail/labelMenu.js +134 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +8 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +15 -3
- package/dist/apps/shared/components/mail/listPreferences.d.ts +101 -0
- package/dist/apps/shared/components/mail/listPreferences.js +186 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +0 -1
- package/dist/apps/shared/styles/app.css +28 -5
- package/dist/apps/www/index.js +506 -112
- package/dist/apps/www/messages/[uid].js +2 -3
- package/package.json +2 -2
- package/apps/www/settings/focused-inbox/index.tsx +0 -150
- package/dist/apps/www/settings/focused-inbox/index.d.ts +0 -4
- package/dist/apps/www/settings/focused-inbox/index.js +0 -71
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } 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 { HiOutlineFolder, HiOutlineFolderPlus } from "react-icons/hi2";
|
|
8
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
9
|
+
import { createFolder } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
10
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
11
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
12
|
+
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
13
|
+
/**
|
|
14
|
+
* The folder types a message can be moved *into* - Outbox is a transient send queue the server owns, and
|
|
15
|
+
* the non-mail folders (`calendar`/`contacts`/`tasks`/`notes`) aren't message folders at all, so neither is
|
|
16
|
+
* offered. Shared by the reading pane's Move to and select mode's bulk Move to, which is the whole point of
|
|
17
|
+
* this component: one destination list, one set of rules.
|
|
18
|
+
*/
|
|
19
|
+
export const MOVE_TARGET_TYPES = new Set(["inbox", "drafts", "sent_items", "junk", "archive", "deleted_items", "user"]);
|
|
20
|
+
/** Above this many destinations the dialog offers a filter box - below it, one is just another thing to
|
|
21
|
+
* skip past on the way to a list that already fits on screen. */
|
|
22
|
+
export const FOLDER_FILTER_THRESHOLD = 8;
|
|
23
|
+
/** What `@rapidmx/restapi` accepts as a folder name, so a name it would refuse is refused here, next to the
|
|
24
|
+
* field, rather than as a 400 after a round trip. */
|
|
25
|
+
export const MAX_FOLDER_NAME_LENGTH = 255;
|
|
26
|
+
/**
|
|
27
|
+
* Why a name can't be used, or `null` when it can. Folders are a real tree (`Folder.parentFolderUid`), not
|
|
28
|
+
* paths, so a separator in a name would be a character in the name rather than a nesting instruction -
|
|
29
|
+
* which is exactly why it is refused: it would read as a hierarchy this app never created.
|
|
30
|
+
*/
|
|
31
|
+
export function folderNameError(name, existing) {
|
|
32
|
+
const trimmed = name.trim();
|
|
33
|
+
if (!trimmed) {
|
|
34
|
+
return "Enter a name for the new folder.";
|
|
35
|
+
}
|
|
36
|
+
if (trimmed.length > MAX_FOLDER_NAME_LENGTH) {
|
|
37
|
+
return `A folder name can be at most ${MAX_FOLDER_NAME_LENGTH} characters.`;
|
|
38
|
+
}
|
|
39
|
+
if (/[/\\]/.test(trimmed)) {
|
|
40
|
+
return "A folder name can't contain / or \\.";
|
|
41
|
+
}
|
|
42
|
+
const clash = existing.find((folder) => folder.name.toLowerCase() === trimmed.toLowerCase());
|
|
43
|
+
if (clash) {
|
|
44
|
+
return `This mailbox already has a folder called "${clash.name}". Pick it from the list instead.`;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Outlook's "Move to" prompt: pick a destination folder for the open message or the selection, or create a
|
|
50
|
+
* folder and move into it in one step.
|
|
51
|
+
*
|
|
52
|
+
* A `Modal` rather than a `MenuButton` because creating a folder needs a text field, and a `role="menu"`
|
|
53
|
+
* has nowhere to put one (the same reason `NewLabelDialog` exists) - and because a destination list can be
|
|
54
|
+
* as long as the mailbox has folders, which wants a filter box rather than a scrolling popup.
|
|
55
|
+
*
|
|
56
|
+
* A new folder is created at the **top level of the mailbox**, typed `user`, never as a child of the folder
|
|
57
|
+
* being moved out of: this app's sidebar lists folders flat (`MailShell`'s own `FOLDER_ORDER`), so a
|
|
58
|
+
* subfolder would be created into a hierarchy nothing here renders.
|
|
59
|
+
*/
|
|
60
|
+
export default function MoveToFolderDialog({ open, onClose, mailboxUid, folders, currentFolderUid, count = 1, onMove, onFolderCreated, }) {
|
|
61
|
+
const [filter, setFilter] = useState("");
|
|
62
|
+
const [creating, setCreating] = useState(false);
|
|
63
|
+
const [newName, setNewName] = useState("");
|
|
64
|
+
const [busy, setBusy] = useState(false);
|
|
65
|
+
const [error, setError] = useState(null);
|
|
66
|
+
const nameRef = useRef(null);
|
|
67
|
+
// Every opening starts from the same place, so a filter or a half-typed folder name from last time is
|
|
68
|
+
// never what the next move is made against.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (open) {
|
|
71
|
+
setFilter("");
|
|
72
|
+
setCreating(false);
|
|
73
|
+
setNewName("");
|
|
74
|
+
setBusy(false);
|
|
75
|
+
setError(null);
|
|
76
|
+
}
|
|
77
|
+
}, [open]);
|
|
78
|
+
// The field is only mounted once "New folder" is chosen, so focus moves to it when it appears.
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (creating) {
|
|
81
|
+
nameRef.current?.focus();
|
|
82
|
+
}
|
|
83
|
+
}, [creating]);
|
|
84
|
+
const targets = folders.filter((folder) => MOVE_TARGET_TYPES.has(folder.type));
|
|
85
|
+
const shown = filter.trim()
|
|
86
|
+
? targets.filter((folder) => folder.name.toLowerCase().includes(filter.trim().toLowerCase()))
|
|
87
|
+
: targets;
|
|
88
|
+
const offerFilter = targets.length > FOLDER_FILTER_THRESHOLD;
|
|
89
|
+
const noun = count === 1 ? "message" : `${count} messages`;
|
|
90
|
+
async function moveTo(folderUid) {
|
|
91
|
+
setBusy(true);
|
|
92
|
+
setError(null);
|
|
93
|
+
try {
|
|
94
|
+
await onMove(folderUid);
|
|
95
|
+
onClose();
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not move to that folder.");
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
setBusy(false);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function handleCreate(e) {
|
|
105
|
+
e.preventDefault();
|
|
106
|
+
const invalid = folderNameError(newName, folders);
|
|
107
|
+
if (invalid) {
|
|
108
|
+
setError(invalid);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
setBusy(true);
|
|
112
|
+
setError(null);
|
|
113
|
+
let created;
|
|
114
|
+
try {
|
|
115
|
+
created = await createFolder({ mailboxUid, name: newName.trim(), type: "user" });
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not create that folder.");
|
|
119
|
+
setBusy(false);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
// Handed over before the move: the folder exists from here on whether or not the move lands, and
|
|
123
|
+
// the reader would otherwise have to create it again to retry.
|
|
124
|
+
onFolderCreated?.(created);
|
|
125
|
+
setBusy(false);
|
|
126
|
+
await moveTo(created.uid);
|
|
127
|
+
}
|
|
128
|
+
return (_jsxs(Modal, { open: open, onClose: () => !busy && onClose(), title: `Move ${noun} to`, children: [offerFilter && !creating && (_jsx("input", { type: "search", value: filter, onChange: (e) => setFilter(e.target.value), "aria-label": "Filter folders", placeholder: "Filter folders\u2026", className: "w-full text-sm px-3 py-1.5 mb-2 rounded-md border border-border bg-surface" })), !creating && (_jsxs("ul", { className: "max-h-72 overflow-y-auto border border-border rounded-md divide-y divide-border", children: [shown.map((folder) => {
|
|
129
|
+
const here = folder.uid === currentFolderUid;
|
|
130
|
+
return (_jsx("li", { children: _jsxs("button", { type: "button", onClick: () => void moveTo(folder.uid), disabled: busy || here,
|
|
131
|
+
// Disabled rather than hidden, so the list doesn't change shape as the
|
|
132
|
+
// reader moves between folders.
|
|
133
|
+
title: here ? `Already in ${folder.name}` : undefined, className: "w-full text-left flex items-center gap-2 px-3 py-2 text-sm hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent", children: [_jsx(HiOutlineFolder, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), _jsx("span", { className: "truncate", children: folder.name }), here && _jsx("span", { className: "ml-auto text-xs text-text-muted shrink-0", children: "Already here" })] }) }, folder.uid));
|
|
134
|
+
}), shown.length === 0 && (_jsx("li", { className: "px-3 py-2 text-sm text-text-muted", children: targets.length === 0 ? "This mailbox has no folders to move to yet." : "No folder matches that." }))] })), creating ? (_jsxs("form", { onSubmit: handleCreate, children: [_jsx("label", { className: "block text-sm font-medium mb-1", htmlFor: "move-to-new-folder", children: "New folder name" }), _jsx("input", { id: "move-to-new-folder", ref: nameRef, value: newName, onChange: (e) => setNewName(e.target.value), maxLength: MAX_FOLDER_NAME_LENGTH, className: "w-full text-sm px-3 py-1.5 rounded-md border border-border bg-surface" }), _jsxs("p", { className: "text-xs text-text-muted mt-1", children: ["Created at the top level of this mailbox, and the ", count === 1 ? "message is" : "messages are", " moved into it."] }), error && (_jsx("div", { className: "mt-3", children: _jsx(Alert, { children: error }) })), _jsxs("div", { className: "flex justify-end gap-2 mt-4", children: [_jsx(Button, { type: "button", variant: "secondary", onClick: () => setCreating(false), disabled: busy, children: "Back" }), _jsx(Button, { type: "submit", disabled: busy, children: busy ? "Creating…" : "Create and move" })] })] })) : (_jsxs(_Fragment, { children: [_jsxs("button", { type: "button", onClick: () => {
|
|
135
|
+
setCreating(true);
|
|
136
|
+
setError(null);
|
|
137
|
+
}, disabled: busy, className: "mt-3 inline-flex items-center gap-1.5 text-sm font-medium text-primary-dark hover:underline disabled:opacity-50 disabled:no-underline", children: [_jsx(HiOutlineFolderPlus, { size: 16, "aria-hidden": "true" }), "New folder\u2026"] }), error && (_jsx("div", { className: "mt-3", children: _jsx(Alert, { children: error }) }))] }))] }));
|
|
138
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React, { PropsWithChildren } from "react";
|
|
2
|
+
import { DraftThreading } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
2
3
|
export interface ComposeSession {
|
|
3
4
|
id: string;
|
|
4
5
|
/** The initial sending mailbox - absent means "the caller's own mailbox" (see `ComposeWindow`). */
|
|
@@ -7,8 +8,13 @@ export interface ComposeSession {
|
|
|
7
8
|
initialCc?: string;
|
|
8
9
|
initialSubject?: string;
|
|
9
10
|
/** Pre-built HTML (already includes its own quote-attribution wrapper — see `composeQuoting.ts`)
|
|
10
|
-
* inserted below the resolved default signature. Absent for a fresh, non-reply/forward compose.
|
|
11
|
+
* inserted below the resolved default signature. Absent for a fresh, non-reply/forward compose. Its presence
|
|
12
|
+
* is what makes the window open with the caret at the top of the body instead of in To. */
|
|
11
13
|
initialQuotedHtml?: string;
|
|
14
|
+
/** See `OpenComposeInput.encrypt`'s own doc comment. */
|
|
15
|
+
initialEncrypt?: boolean;
|
|
16
|
+
/** See `OpenComposeInput.threading`'s own doc comment - handed to `createDraft()` by the window. */
|
|
17
|
+
threading?: DraftThreading;
|
|
12
18
|
/** Which of a signature's two "default" flags to resolve against — `"new"` (the default) uses
|
|
13
19
|
* `isDefaultForNewMessages`, `"reply_forward"` uses `isDefaultForReplyForward`. */
|
|
14
20
|
signatureContext: "new" | "reply_forward";
|
|
@@ -38,6 +44,15 @@ export interface OpenComposeInput {
|
|
|
38
44
|
* "Mailing lists" note), so the new compose window defaults its Sign toggle off rather than on.
|
|
39
45
|
* The user can still turn it back on manually; this only changes the *default*. */
|
|
40
46
|
suppressSigning?: boolean;
|
|
47
|
+
/** `true` for a reply to or forward of an encrypted message, whose quote may carry its decrypted content: the
|
|
48
|
+
* compose window starts with "Encrypt this message" requested, so it is never autosaved as a plaintext draft and
|
|
49
|
+
* can't be sent unencrypted without the user explicitly choosing to. */
|
|
50
|
+
encrypt?: boolean;
|
|
51
|
+
/** The thread this compose continues (`buildReplyThreading()` over the message being replied to or
|
|
52
|
+
* forwarded), recorded on the draft by `createDraft()`. Without it the message is relayed with no
|
|
53
|
+
* `In-Reply-To`/`References` at all and every mail system - the sender's own Sent Items included - files it
|
|
54
|
+
* as a new conversation rather than part of the thread. Absent for a fresh compose, which starts one. */
|
|
55
|
+
threading?: DraftThreading;
|
|
41
56
|
}
|
|
42
57
|
export interface ComposeContextValue {
|
|
43
58
|
/** Opens a new compose window, stacked alongside any already open (Gmail allows several at once). */
|
|
@@ -21,7 +21,7 @@ export function useCompose() {
|
|
|
21
21
|
export default function ComposeProvider({ children, userUid, trusted }) {
|
|
22
22
|
const [sessions, setSessions] = useState([]);
|
|
23
23
|
const isMobile = useIsMobile();
|
|
24
|
-
function openCompose({ mailboxUid, to, cc, subject, quotedHtml, signatureContext = "new", suppressSigning }) {
|
|
24
|
+
function openCompose({ mailboxUid, to, cc, subject, quotedHtml, signatureContext = "new", suppressSigning, encrypt, threading, }) {
|
|
25
25
|
setSessions((prev) => [
|
|
26
26
|
...prev,
|
|
27
27
|
{
|
|
@@ -31,6 +31,8 @@ export default function ComposeProvider({ children, userUid, trusted }) {
|
|
|
31
31
|
initialCc: cc,
|
|
32
32
|
initialSubject: subject,
|
|
33
33
|
initialQuotedHtml: quotedHtml,
|
|
34
|
+
initialEncrypt: encrypt,
|
|
35
|
+
threading,
|
|
34
36
|
signatureContext,
|
|
35
37
|
suppressSigning,
|
|
36
38
|
minimized: false,
|
|
@@ -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 { buildComposeBodyHtml } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
|
|
11
12
|
import { peekMailboxWritability, useMailboxWritability } from "../writableMailboxes.js";
|
|
12
13
|
import { decideMessageEncryption, resolveRecipientEncryption } from "@rapidmx/react-shared/crypto/composeSecurity.js";
|
|
13
14
|
import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
@@ -17,6 +18,8 @@ import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
|
|
|
17
18
|
import { applyBaselineOuterHeaders, assembleOutboundMime, buildEncryptedMessage, buildSignedOnlyMessage } from "@rapidmx/react-shared/crypto/smimeMessage.js";
|
|
18
19
|
import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
|
|
19
20
|
import { isSigningOut, registerComposeFlush } from "./composeFlushRegistry.js";
|
|
21
|
+
import RecipientInput from "./RecipientInput.js";
|
|
22
|
+
import { parseRecipientList } from "./recipients.js";
|
|
20
23
|
import RichTextEditor from "./RichTextEditor.js";
|
|
21
24
|
import ScheduleSendPicker from "./ScheduleSendPicker.js";
|
|
22
25
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
@@ -67,12 +70,9 @@ const MIN_HEIGHT = 320;
|
|
|
67
70
|
// during this app's SSR pass (see `ReactRoute`) — referencing it here would crash every page render,
|
|
68
71
|
// not just this component's.
|
|
69
72
|
const VIEWPORT_MARGIN = 24;
|
|
73
|
+
/** A To/Cc/Bcc field's recipients - see `recipients.ts` for the format. */
|
|
70
74
|
function parseAddresses(value) {
|
|
71
|
-
return value
|
|
72
|
-
.split(",")
|
|
73
|
-
.map((s) => s.trim())
|
|
74
|
-
.filter((s) => s.length > 0)
|
|
75
|
-
.map((address) => ({ address }));
|
|
75
|
+
return parseRecipientList(value);
|
|
76
76
|
}
|
|
77
77
|
function HeaderButton({ label, onClick, icon: Icon, disabled, }) {
|
|
78
78
|
return (_jsx("button", { type: "button", "aria-label": label, title: label, 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 }) }));
|
|
@@ -92,7 +92,7 @@ function HeaderButton({ label, onClick, icon: Icon, disabled, }) {
|
|
|
92
92
|
* once.
|
|
93
93
|
*/
|
|
94
94
|
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, signatureContext, suppressSigning, minimized } = session;
|
|
95
|
+
const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, minimized } = session;
|
|
96
96
|
// The sending ("From") mailbox. A reply/forward session names the original message's mailbox; a fresh
|
|
97
97
|
// compose leaves it unset and defaults to the caller's own mailbox once `listMailboxes()` resolves.
|
|
98
98
|
// Everything mailbox-scoped below (Drafts folder, draft, signatures, crypto context) keys off this.
|
|
@@ -137,7 +137,9 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
137
137
|
// `MessageDetailPane.tsx`'s handleReply()/handleReplyAll() for where this is computed. The user can
|
|
138
138
|
// still turn it back on; this only changes the default.
|
|
139
139
|
const [signEnabled, setSignEnabled] = useState(!suppressSigning);
|
|
140
|
-
|
|
140
|
+
// A reply to or forward of an encrypted message starts (and, after a From switch, restarts) with encryption
|
|
141
|
+
// requested - see `OpenComposeInput.encrypt`.
|
|
142
|
+
const [encryptRequested, setEncryptRequested] = useState(!!initialEncrypt);
|
|
141
143
|
const [encryptionBlocked, setEncryptionBlocked] = useState(null);
|
|
142
144
|
// Compose-time discovery (spec: "Discovery occurs ... when the user addresses a new message to a
|
|
143
145
|
// recipient", never on receipt) - keyed by address so a recipient already looked up isn't re-fetched
|
|
@@ -350,11 +352,12 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
350
352
|
checkRecipientDiscovery([...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]);
|
|
351
353
|
}, [mailbox, encryptionPolicy, lookupRetryToken]);
|
|
352
354
|
// Resolves the mailbox's default signature (if any) for `signatureContext` and seeds `html` with it
|
|
353
|
-
// plus any quoted original message
|
|
355
|
+
// plus any quoted original message (laid out by `buildComposeBodyHtml()`: an empty first paragraph for the
|
|
356
|
+
// caret, then the signature, then the quote), before `RichTextEditor` ever mounts (gated by `contentReady`
|
|
354
357
|
// below) — `RichTextEditor`'s own doc comment is explicit that `value` only seeds its *initial*
|
|
355
358
|
// content and never re-syncs from a later prop change, so this has to resolve before that first
|
|
356
359
|
// mount, not after. A signature-list failure is best-effort, same as every other supplementary,
|
|
357
|
-
// non-blocking fetch in this codebase (e.g. `
|
|
360
|
+
// non-blocking fetch in this codebase (e.g. `ConversationList`'s own child-message fetch) — no
|
|
358
361
|
// signature is a completely legitimate outcome, so this falls back to just the quoted content
|
|
359
362
|
// (if any) rather than surfacing an error over what's a cosmetic nicety.
|
|
360
363
|
useEffect(() => {
|
|
@@ -366,14 +369,14 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
366
369
|
listMailSignatures(mailboxUid)
|
|
367
370
|
.then((signatures) => {
|
|
368
371
|
const signature = signatures.find((s) => signatureContext === "new" ? s.isDefaultForNewMessages : s.isDefaultForReplyForward);
|
|
369
|
-
const
|
|
370
|
-
const seeded = `${signatureHtml}${initialQuotedHtml ?? ""}`;
|
|
372
|
+
const seeded = buildComposeBodyHtml(signature?.contentHtml, initialQuotedHtml);
|
|
371
373
|
setHtml(seeded);
|
|
372
374
|
setSeededHtml(seeded);
|
|
373
375
|
})
|
|
374
376
|
.catch(() => {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
+
const seeded = buildComposeBodyHtml(undefined, initialQuotedHtml);
|
|
378
|
+
setHtml(seeded);
|
|
379
|
+
setSeededHtml(seeded);
|
|
377
380
|
})
|
|
378
381
|
.finally(() => setContentReady(true));
|
|
379
382
|
}, [mailboxUid, signatureContext, initialQuotedHtml]);
|
|
@@ -382,7 +385,11 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
382
385
|
return;
|
|
383
386
|
}
|
|
384
387
|
let cancelled = false;
|
|
385
|
-
|
|
388
|
+
// `session.threading` is what makes a reply a reply: the server writes it into the relayed MIME's
|
|
389
|
+
// `In-Reply-To`/`References` and groups the message into the thread being answered. Recorded on
|
|
390
|
+
// every draft this window creates, including the replacement one a From switch makes - the thread
|
|
391
|
+
// doesn't change because the sending mailbox did.
|
|
392
|
+
createDraft(mailboxUid, draftsFolderUid, session.threading)
|
|
386
393
|
.then((created) => {
|
|
387
394
|
if (cancelled) {
|
|
388
395
|
// The sender changed while this was in flight - discard the now-orphaned draft.
|
|
@@ -404,7 +411,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
404
411
|
return () => {
|
|
405
412
|
cancelled = true;
|
|
406
413
|
};
|
|
407
|
-
}, [mailboxUid, draftsFolderUid, draft]);
|
|
414
|
+
}, [mailboxUid, draftsFolderUid, draft, session.threading]);
|
|
408
415
|
// Drafts a From switch superseded are otherwise only deleted once their replacement exists - closing the
|
|
409
416
|
// window (or sending, which closes it) first would orphan them.
|
|
410
417
|
useEffect(() => () => {
|
|
@@ -477,7 +484,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
477
484
|
lookupsInFlightRef.current = new Set();
|
|
478
485
|
lookupFailuresRef.current = new Map();
|
|
479
486
|
setExhaustedLookups({});
|
|
480
|
-
setEncryptRequested(
|
|
487
|
+
setEncryptRequested(!!initialEncrypt);
|
|
481
488
|
setEncryptionBlocked(null);
|
|
482
489
|
setSecurityBlock(null);
|
|
483
490
|
offeredCryptoRef.current = { sign: false, encrypt: false };
|
|
@@ -1017,8 +1024,8 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1017
1024
|
* a current recipient's key lookup kept failing. */
|
|
1018
1025
|
const cryptoCheckUnavailable = (cryptoContextReady && cryptoLoadFailed && (!mailbox || (encryptionPossible && !encryptionPolicy))) ||
|
|
1019
1026
|
(encryptionPossible && currentAddresses.some((address) => exhaustedLookups[address] && !recipientStatuses[address]));
|
|
1020
|
-
const latestRef = useRef({ draft, to, cc, bcc, subject, html, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided });
|
|
1021
|
-
latestRef.current = { draft, to, cc, bcc, subject, html, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided };
|
|
1027
|
+
const latestRef = useRef({ draft, to, cc, bcc, subject, html, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided });
|
|
1028
|
+
latestRef.current = { draft, to, cc, bcc, subject, html, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided };
|
|
1022
1029
|
useEffect(() => {
|
|
1023
1030
|
const due = !!draft &&
|
|
1024
1031
|
contentReady &&
|
|
@@ -1092,6 +1099,31 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1092
1099
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
1093
1100
|
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
1094
1101
|
}, []);
|
|
1102
|
+
// Where the caret starts, once per session: a reply/forward in the body (at its top), a new message in To - or,
|
|
1103
|
+
// with To already filled in (e.g. Contacts' "Email"), in Subject. Minimizing unmounts the fields, so restoring a
|
|
1104
|
+
// window must not move the caret there again.
|
|
1105
|
+
const isReplyOrForward = initialQuotedHtml !== undefined;
|
|
1106
|
+
const fieldsFocusedRef = useRef(false);
|
|
1107
|
+
const bodyFocusedRef = useRef(false);
|
|
1108
|
+
const focusToOnMount = !isReplyOrForward && !initialTo && !fieldsFocusedRef.current;
|
|
1109
|
+
const focusSubjectOnMount = !isReplyOrForward && !!initialTo && !fieldsFocusedRef.current;
|
|
1110
|
+
const focusBodyOnMount = isReplyOrForward && !bodyFocusedRef.current;
|
|
1111
|
+
// The editor serializes the seeded body its own way (see `RichTextEditor`'s `onInitialized`). While the body is
|
|
1112
|
+
// still exactly what compose seeded, that serialization becomes the baseline - so an untouched reply or signature
|
|
1113
|
+
// isn't mistaken for an edit (and autosaved) just because the editor was focused or clicked.
|
|
1114
|
+
function handleEditorInitialized(normalized) {
|
|
1115
|
+
const latest = latestRef.current;
|
|
1116
|
+
if (latest.html === latest.seededHtml && normalized !== latest.html) {
|
|
1117
|
+
setHtml(normalized);
|
|
1118
|
+
setSeededHtml(normalized);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
useEffect(() => {
|
|
1122
|
+
if (!minimized) {
|
|
1123
|
+
fieldsFocusedRef.current = true;
|
|
1124
|
+
bodyFocusedRef.current || (bodyFocusedRef.current = contentReady);
|
|
1125
|
+
}
|
|
1126
|
+
}, [minimized, contentReady]);
|
|
1095
1127
|
const title = subject.trim() || "New Message";
|
|
1096
1128
|
const titleId = `compose-title-${id}`;
|
|
1097
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" })), _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" })] })] }));
|
|
@@ -1103,7 +1135,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1103
1135
|
isMobile
|
|
1104
1136
|
? "fixed inset-0 w-full h-full rounded-none border-0"
|
|
1105
1137
|
: ["border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
|
|
1106
|
-
].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(Alert, { children: sendError }), 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(
|
|
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(Alert, { children: sendError }), 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)) })] })] })), (() => {
|
|
1107
1139
|
const knownRecipients = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]
|
|
1108
1140
|
.map((r) => recipientStatuses[r.address])
|
|
1109
1141
|
.filter((status) => !!status);
|
|
@@ -1111,5 +1143,5 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1111
1143
|
return null;
|
|
1112
1144
|
}
|
|
1113
1145
|
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))) }));
|
|
1114
|
-
})(), _jsx("div", { className: FIELD_ROW, children: _jsx("input", { "aria-label": "Subject", type: "text", placeholder: "Subject", className: FIELD_INPUT, value: subject, 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 }) }), 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] }));
|
|
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] }));
|
|
1115
1147
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type ContactSuggestionOptions, type RecipientSuggestion } from "@rapidmx/react-shared/mail/directoryApi.js";
|
|
3
|
+
/** How long typing must pause before suggestions are requested. */
|
|
4
|
+
export declare const RECIPIENT_SUGGESTION_DEBOUNCE_MS = 150;
|
|
5
|
+
export interface RecipientInputProps {
|
|
6
|
+
/** The input's id, which the field's `<label htmlFor>` names. Also prefixes the listbox and option ids. */
|
|
7
|
+
id: string;
|
|
8
|
+
/** The field's name ("To", "Cc", "Bcc"), used in the recipient list's and suggestions' accessible names. */
|
|
9
|
+
label: string;
|
|
10
|
+
/** The field's text: recipients separated by commas, as `parseRecipientList()` reads it. */
|
|
11
|
+
value: string;
|
|
12
|
+
onChange: (value: string) => void;
|
|
13
|
+
/** Called with the field's text when focus leaves the input. */
|
|
14
|
+
onBlur?: (value: string) => void;
|
|
15
|
+
/** Called with the field's text after a suggestion is picked (focus stays in the input). */
|
|
16
|
+
onCommit?: (value: string) => void;
|
|
17
|
+
/** The mailbox being composed from, whose contacts are suggested too. */
|
|
18
|
+
mailboxUid?: string;
|
|
19
|
+
/** Loads suggestions; defaults to react-shared's `fetchRecipientSuggestions()`. */
|
|
20
|
+
fetchSuggestions?: (query: string, options: ContactSuggestionOptions) => Promise<RecipientSuggestion[]>;
|
|
21
|
+
debounceMs?: number;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
/** Focuses the input when it mounts - a new message starts in To. */
|
|
24
|
+
autoFocus?: boolean;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* A To/Cc/Bcc field: committed recipients as removable chips, and a WAI-ARIA combobox for the one being typed that
|
|
28
|
+
* suggests the caller's contacts and the server directory (contacts first, one entry per address). The field's value is
|
|
29
|
+
* still plain text (recipients separated by commas), so drafts, autosave and sending read it as before.
|
|
30
|
+
*
|
|
31
|
+
* Typing a comma or semicolon (outside a quoted name or angle brackets), pressing Enter, or leaving the field turns the
|
|
32
|
+
* typed text into a chip; pasted lists split the same way. Suggestions load `debounceMs` after typing pauses, for at
|
|
33
|
+
* least two characters, and a newer query aborts an older request. Arrow keys move through them, Enter or Tab picks
|
|
34
|
+
* one (as `Name <address>`), Escape closes them, and Backspace in an empty input removes the last chip.
|
|
35
|
+
*/
|
|
36
|
+
export default function RecipientInput({ id, label, value, onChange, onBlur, onCommit, mailboxUid, fetchSuggestions, debounceMs, disabled, autoFocus, }: RecipientInputProps): React.JSX.Element;
|