@rapidmx/web-client 0.6.0 → 0.8.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.
Files changed (79) hide show
  1. package/apps/shared/components/admin/settings/BrandingForm.tsx +398 -385
  2. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -112
  3. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -152
  4. package/apps/shared/components/admin/settings/PluginsManager.tsx +1288 -1235
  5. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +9 -1
  6. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -290
  7. package/apps/shared/components/admin/setup/SetupWizard.tsx +443 -416
  8. package/apps/shared/components/layout/AppShell.tsx +288 -288
  9. package/apps/shared/components/mail/ConversationList.tsx +225 -33
  10. package/apps/shared/components/mail/ConversationThreadPane.tsx +434 -216
  11. package/apps/shared/components/mail/MailListToolbar.tsx +265 -0
  12. package/apps/shared/components/mail/MailSelectionBar.tsx +222 -0
  13. package/apps/shared/components/mail/MenuButton.tsx +384 -0
  14. package/apps/shared/components/mail/MessageDetailPane.tsx +280 -215
  15. package/apps/shared/components/mail/MoveToFolderDialog.tsx +263 -0
  16. package/apps/shared/components/mail/compose/ComposeContext.tsx +29 -2
  17. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1700 -1652
  18. package/apps/shared/components/mail/compose/RecipientInput.tsx +356 -0
  19. package/apps/shared/components/mail/compose/RichTextEditor.tsx +38 -3
  20. package/apps/shared/components/mail/compose/quotedBody.ts +100 -0
  21. package/apps/shared/components/mail/compose/recipients.ts +104 -0
  22. package/apps/shared/components/mail/labelMenu.tsx +278 -0
  23. package/apps/shared/components/mail/layout/MailShell.tsx +445 -420
  24. package/apps/shared/components/mail/listPreferences.ts +228 -0
  25. package/apps/shared/components/settings/layout/SettingsShell.tsx +0 -1
  26. package/apps/shared/styles/app.css +28 -5
  27. package/apps/www/index.tsx +1854 -1314
  28. package/apps/www/messages/[uid].tsx +5 -4
  29. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +7 -3
  30. package/dist/apps/shared/components/admin/settings/BrandingForm.js +6 -5
  31. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.d.ts +4 -1
  32. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +2 -2
  33. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +9 -2
  34. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +20 -4
  35. package/dist/apps/shared/components/admin/settings/PluginsManager.d.ts +8 -1
  36. package/dist/apps/shared/components/admin/settings/PluginsManager.js +33 -14
  37. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.d.ts +4 -1
  38. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +2 -2
  39. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +1 -1
  40. package/dist/apps/shared/components/admin/setup/SetupWizard.js +14 -10
  41. package/dist/apps/shared/components/mail/ConversationList.d.ts +42 -6
  42. package/dist/apps/shared/components/mail/ConversationList.js +84 -10
  43. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +52 -13
  44. package/dist/apps/shared/components/mail/ConversationThreadPane.js +217 -60
  45. package/dist/apps/shared/components/mail/MailListToolbar.d.ts +55 -0
  46. package/dist/apps/shared/components/mail/MailListToolbar.js +149 -0
  47. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +59 -0
  48. package/dist/apps/shared/components/mail/MailSelectionBar.js +48 -0
  49. package/dist/apps/shared/components/mail/MenuButton.d.ts +71 -0
  50. package/dist/apps/shared/components/mail/MenuButton.js +211 -0
  51. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +27 -19
  52. package/dist/apps/shared/components/mail/MessageDetailPane.js +126 -129
  53. package/dist/apps/shared/components/mail/MoveToFolderDialog.d.ts +57 -0
  54. package/dist/apps/shared/components/mail/MoveToFolderDialog.js +138 -0
  55. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +16 -1
  56. package/dist/apps/shared/components/mail/compose/ComposeContext.js +3 -1
  57. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +52 -20
  58. package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +36 -0
  59. package/dist/apps/shared/components/mail/compose/RecipientInput.js +203 -0
  60. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +10 -1
  61. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +21 -3
  62. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +37 -0
  63. package/dist/apps/shared/components/mail/compose/quotedBody.js +85 -0
  64. package/dist/apps/shared/components/mail/compose/recipients.d.ts +24 -0
  65. package/dist/apps/shared/components/mail/compose/recipients.js +87 -0
  66. package/dist/apps/shared/components/mail/labelMenu.d.ts +97 -0
  67. package/dist/apps/shared/components/mail/labelMenu.js +134 -0
  68. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +8 -0
  69. package/dist/apps/shared/components/mail/layout/MailShell.js +15 -3
  70. package/dist/apps/shared/components/mail/listPreferences.d.ts +101 -0
  71. package/dist/apps/shared/components/mail/listPreferences.js +186 -0
  72. package/dist/apps/shared/components/settings/layout/SettingsShell.js +0 -1
  73. package/dist/apps/shared/styles/app.css +28 -5
  74. package/dist/apps/www/index.js +506 -112
  75. package/dist/apps/www/messages/[uid].js +2 -3
  76. package/package.json +2 -2
  77. package/apps/www/settings/focused-inbox/index.tsx +0 -150
  78. package/dist/apps/www/settings/focused-inbox/index.d.ts +0 -4
  79. 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
- const [encryptRequested, setEncryptRequested] = useState(false);
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, before `RichTextEditor` ever mounts (gated by `contentReady`
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. `ConversationThreadPane`'s attachment fetch) — no
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 signatureHtml = signature?.contentHtml ? `${signature.contentHtml}<p></p>` : "";
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
- setHtml(initialQuotedHtml ?? "");
376
- setSeededHtml(initialQuotedHtml ?? "");
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
- createDraft(mailboxUid, draftsFolderUid)
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(false);
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("input", { id: `compose-to-${id}`, type: "text", className: FIELD_INPUT, value: to, onChange: (e) => setTo(e.target.value), onBlur: () => checkRecipientDiscovery(parseAddresses(to)) }), !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("input", { id: `compose-cc-${id}`, type: "text", className: FIELD_INPUT, value: cc, onChange: (e) => setCc(e.target.value), onBlur: () => checkRecipientDiscovery(parseAddresses(cc)) })] }), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-bcc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Bcc" }), _jsx("input", { id: `compose-bcc-${id}`, type: "text", className: FIELD_INPUT, value: bcc, onChange: (e) => setBcc(e.target.value), onBlur: () => checkRecipientDiscovery(parseAddresses(bcc)) })] })] })), (() => {
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;