@rapidmx/web-client 0.5.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.
Files changed (224) hide show
  1. package/README.md +88 -0
  2. package/apps/shared/components/admin/layout/AdminShell.tsx +25 -8
  3. package/apps/shared/components/admin/settings/BrandingForm.tsx +398 -385
  4. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -112
  5. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +159 -152
  6. package/apps/shared/components/admin/settings/PluginsManager.tsx +1288 -1235
  7. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +9 -1
  8. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -290
  9. package/apps/shared/components/admin/setup/SetupWizard.tsx +443 -416
  10. package/apps/shared/components/calendar/layout/CalendarShell.tsx +194 -192
  11. package/apps/shared/components/contacts/layout/ContactsShell.tsx +199 -197
  12. package/apps/shared/components/layout/AppShell.tsx +288 -270
  13. package/apps/shared/components/mail/ConversationList.tsx +225 -33
  14. package/apps/shared/components/mail/ConversationThreadPane.tsx +434 -216
  15. package/apps/shared/components/mail/MailListToolbar.tsx +265 -0
  16. package/apps/shared/components/mail/MailSelectionBar.tsx +222 -0
  17. package/apps/shared/components/mail/MenuButton.tsx +384 -0
  18. package/apps/shared/components/mail/MessageDetailPane.tsx +280 -215
  19. package/apps/shared/components/mail/MoveToFolderDialog.tsx +263 -0
  20. package/apps/shared/components/mail/compose/ComposeContext.tsx +29 -2
  21. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1700 -1652
  22. package/apps/shared/components/mail/compose/RecipientInput.tsx +356 -0
  23. package/apps/shared/components/mail/compose/RichTextEditor.tsx +38 -3
  24. package/apps/shared/components/mail/compose/quotedBody.ts +100 -0
  25. package/apps/shared/components/mail/compose/recipients.ts +104 -0
  26. package/apps/shared/components/mail/labelMenu.tsx +278 -0
  27. package/apps/shared/components/mail/layout/MailShell.tsx +31 -4
  28. package/apps/shared/components/mail/listPreferences.ts +228 -0
  29. package/apps/shared/components/settings/layout/SettingsShell.tsx +23 -10
  30. package/apps/shared/components/tasks/layout/TasksShell.tsx +201 -199
  31. package/apps/shared/plugins/pluginNav.ts +73 -0
  32. package/apps/shared/search/localIndexRpcClient.ts +3 -1
  33. package/apps/shared/styles/app.css +28 -5
  34. package/apps/www/index.tsx +1854 -1314
  35. package/apps/www/messages/[uid].tsx +5 -4
  36. package/dist/apps/admin/_404.d.ts +2 -0
  37. package/dist/apps/admin/_500.d.ts +2 -0
  38. package/dist/apps/admin/_layout.d.ts +8 -0
  39. package/dist/apps/admin/audit-log/index.d.ts +11 -0
  40. package/dist/apps/admin/branding/index.d.ts +3 -0
  41. package/dist/apps/admin/data-requests/index.d.ts +3 -0
  42. package/dist/apps/admin/distribution-lists/[uid].d.ts +7 -0
  43. package/dist/apps/admin/distribution-lists/index.d.ts +3 -0
  44. package/dist/apps/admin/distribution-lists/new/index.d.ts +3 -0
  45. package/dist/apps/admin/domains/[uid].d.ts +7 -0
  46. package/dist/apps/admin/domains/index.d.ts +3 -0
  47. package/dist/apps/admin/domains/new/index.d.ts +3 -0
  48. package/dist/apps/admin/encryption-policy/index.d.ts +3 -0
  49. package/dist/apps/admin/escrow-scopes/[uid].d.ts +21 -0
  50. package/dist/apps/admin/escrow-scopes/index.d.ts +3 -0
  51. package/dist/apps/admin/escrow-scopes/new/index.d.ts +3 -0
  52. package/dist/apps/admin/index.d.ts +3 -0
  53. package/dist/apps/admin/ingest-queue/index.d.ts +4 -0
  54. package/dist/apps/admin/mailbox-policy/index.d.ts +3 -0
  55. package/dist/apps/admin/mailboxes/[uid].d.ts +7 -0
  56. package/dist/apps/admin/mailboxes/new/index.d.ts +3 -0
  57. package/dist/apps/admin/plugins/index.d.ts +3 -0
  58. package/dist/apps/admin/quarantine/index.d.ts +6 -0
  59. package/dist/apps/admin/retention-policy/index.d.ts +3 -0
  60. package/dist/apps/admin/setup/index.d.ts +3 -0
  61. package/dist/apps/admin/transport-rules/[uid].d.ts +7 -0
  62. package/dist/apps/admin/transport-rules/_transportRuleConfig.d.ts +21 -0
  63. package/dist/apps/admin/transport-rules/index.d.ts +3 -0
  64. package/dist/apps/admin/transport-rules/new/index.d.ts +3 -0
  65. package/dist/apps/escrow/_layout.d.ts +10 -0
  66. package/dist/apps/escrow/audit-log/index.d.ts +9 -0
  67. package/dist/apps/escrow/index.d.ts +3 -0
  68. package/dist/apps/escrow/matters/[uid].d.ts +7 -0
  69. package/dist/apps/escrow/matters/new/index.d.ts +3 -0
  70. package/dist/apps/shared/components/admin/distributionLists/MemberListCard.d.ts +17 -0
  71. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.d.ts +32 -0
  72. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +32 -0
  73. package/dist/apps/shared/components/admin/layout/AdminShell.js +12 -5
  74. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.d.ts +12 -0
  75. package/dist/apps/shared/components/admin/mailboxes/MailboxTable.d.ts +6 -0
  76. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.d.ts +19 -0
  77. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +12 -0
  78. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +13 -0
  79. package/dist/apps/shared/components/admin/settings/BrandingForm.js +6 -5
  80. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.d.ts +12 -0
  81. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.d.ts +15 -0
  82. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +2 -2
  83. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.d.ts +9 -0
  84. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.d.ts +19 -0
  85. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +13 -0
  86. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +2 -2
  87. package/dist/apps/shared/components/admin/settings/PluginsManager.d.ts +11 -0
  88. package/dist/apps/shared/components/admin/settings/PluginsManager.js +33 -14
  89. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.d.ts +12 -0
  90. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +2 -2
  91. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.d.ts +14 -0
  92. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +1 -1
  93. package/dist/apps/shared/components/admin/setup/SetupWizard.d.ts +36 -0
  94. package/dist/apps/shared/components/admin/setup/SetupWizard.js +14 -10
  95. package/dist/apps/shared/components/admin/signOut.d.ts +23 -0
  96. package/dist/apps/shared/components/admin/usePagedList.d.ts +39 -0
  97. package/dist/apps/shared/components/calendar/CalendarListSidebar.d.ts +26 -0
  98. package/dist/apps/shared/components/calendar/EventModal.d.ts +46 -0
  99. package/dist/apps/shared/components/calendar/MonthView.d.ts +15 -0
  100. package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +20 -0
  101. package/dist/apps/shared/components/calendar/ResourcePicker.d.ts +19 -0
  102. package/dist/apps/shared/components/calendar/SplitDayView.d.ts +28 -0
  103. package/dist/apps/shared/components/calendar/TimeGridView.d.ts +17 -0
  104. package/dist/apps/shared/components/calendar/allDay.d.ts +51 -0
  105. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +40 -0
  106. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +2 -2
  107. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +23 -0
  108. package/dist/apps/shared/components/contacts/ContactForm.d.ts +20 -0
  109. package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +40 -0
  110. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +25 -0
  111. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +40 -0
  112. package/dist/apps/shared/components/contacts/contactKeys.d.ts +29 -0
  113. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +21 -0
  114. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +2 -2
  115. package/dist/apps/shared/components/escrow/layout/EscrowShell.d.ts +25 -0
  116. package/dist/apps/shared/components/forms/StringListField.d.ts +22 -0
  117. package/dist/apps/shared/components/layout/AppShell.d.ts +49 -0
  118. package/dist/apps/shared/components/layout/AppShell.js +15 -13
  119. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +18 -0
  120. package/dist/apps/shared/components/layout/KeyEnrollmentGate.d.ts +51 -0
  121. package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +13 -0
  122. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.d.ts +66 -0
  123. package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +45 -0
  124. package/dist/apps/shared/components/layout/UserMenu.d.ts +23 -0
  125. package/dist/apps/shared/components/mail/ConversationList.d.ts +47 -0
  126. package/dist/apps/shared/components/mail/ConversationList.js +84 -10
  127. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +62 -0
  128. package/dist/apps/shared/components/mail/ConversationThreadPane.js +217 -60
  129. package/dist/apps/shared/components/mail/MailListToolbar.d.ts +55 -0
  130. package/dist/apps/shared/components/mail/MailListToolbar.js +149 -0
  131. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +59 -0
  132. package/dist/apps/shared/components/mail/MailSelectionBar.js +48 -0
  133. package/dist/apps/shared/components/mail/MenuButton.d.ts +71 -0
  134. package/dist/apps/shared/components/mail/MenuButton.js +211 -0
  135. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +101 -0
  136. package/dist/apps/shared/components/mail/MessageDetailPane.js +126 -129
  137. package/dist/apps/shared/components/mail/MoveToFolderDialog.d.ts +57 -0
  138. package/dist/apps/shared/components/mail/MoveToFolderDialog.js +138 -0
  139. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +73 -0
  140. package/dist/apps/shared/components/mail/compose/ComposeContext.js +3 -1
  141. package/dist/apps/shared/components/mail/compose/ComposeToolbar.d.ts +15 -0
  142. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +37 -0
  143. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +52 -20
  144. package/dist/apps/shared/components/mail/compose/EmojiPicker.d.ts +14 -0
  145. package/dist/apps/shared/components/mail/compose/GifPicker.d.ts +13 -0
  146. package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +36 -0
  147. package/dist/apps/shared/components/mail/compose/RecipientInput.js +203 -0
  148. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +43 -0
  149. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +21 -3
  150. package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.d.ts +16 -0
  151. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.d.ts +27 -0
  152. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +37 -0
  153. package/dist/apps/shared/components/mail/compose/quotedBody.js +85 -0
  154. package/dist/apps/shared/components/mail/compose/recipients.d.ts +24 -0
  155. package/dist/apps/shared/components/mail/compose/recipients.js +87 -0
  156. package/dist/apps/shared/components/mail/labelMenu.d.ts +97 -0
  157. package/dist/apps/shared/components/mail/labelMenu.js +134 -0
  158. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +65 -0
  159. package/dist/apps/shared/components/mail/layout/MailShell.js +17 -5
  160. package/dist/apps/shared/components/mail/listPreferences.d.ts +101 -0
  161. package/dist/apps/shared/components/mail/listPreferences.js +186 -0
  162. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +33 -0
  163. package/dist/apps/shared/components/mail/verificationSeals.d.ts +22 -0
  164. package/dist/apps/shared/components/mail/writableMailboxes.d.ts +15 -0
  165. package/dist/apps/shared/components/rules/RuleBuilder.d.ts +70 -0
  166. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +41 -0
  167. package/dist/apps/shared/components/settings/layout/SettingsShell.js +19 -10
  168. package/dist/apps/shared/components/tasks/TasksSidebar.d.ts +41 -0
  169. package/dist/apps/shared/components/tasks/TasksToolbar.d.ts +14 -0
  170. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +24 -0
  171. package/dist/apps/shared/components/tasks/layout/TasksShell.js +2 -2
  172. package/dist/apps/shared/mail/findWellKnownFolderUid.d.ts +5 -0
  173. package/dist/apps/shared/mail/listAllPages.d.ts +17 -0
  174. package/dist/apps/shared/plugins/pluginNav.d.ts +39 -0
  175. package/dist/apps/shared/plugins/pluginNav.js +33 -0
  176. package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +13 -0
  177. package/dist/apps/shared/search/localIndexBlockCipher.d.ts +36 -0
  178. package/dist/apps/shared/search/localIndexBuilder.d.ts +85 -0
  179. package/dist/apps/shared/search/localIndexKey.d.ts +4 -0
  180. package/dist/apps/shared/search/localIndexRpcClient.d.ts +71 -0
  181. package/dist/apps/shared/search/localIndexRpcClient.js +3 -1
  182. package/dist/apps/shared/search/localIndexSchema.d.ts +111 -0
  183. package/dist/apps/shared/search/localIndexSizePreference.d.ts +27 -0
  184. package/dist/apps/shared/search/localIndexStorage.d.ts +28 -0
  185. package/dist/apps/shared/search/localIndexVFS.d.ts +101 -0
  186. package/dist/apps/shared/search/localIndexWorker.d.ts +145 -0
  187. package/dist/apps/shared/search/searchTier2.d.ts +30 -0
  188. package/dist/apps/shared/styles/app.css +28 -5
  189. package/dist/apps/www/_404.d.ts +2 -0
  190. package/dist/apps/www/_500.d.ts +2 -0
  191. package/dist/apps/www/_layout.d.ts +14 -0
  192. package/dist/apps/www/calendar/index.d.ts +3 -0
  193. package/dist/apps/www/contacts/[uid].d.ts +13 -0
  194. package/dist/apps/www/contacts/index.d.ts +3 -0
  195. package/dist/apps/www/index.d.ts +3 -0
  196. package/dist/apps/www/index.js +506 -112
  197. package/dist/apps/www/messages/[uid].d.ts +11 -0
  198. package/dist/apps/www/messages/[uid].js +2 -3
  199. package/dist/apps/www/settings/auto-reply/index.d.ts +4 -0
  200. package/dist/apps/www/settings/encryption/index.d.ts +7 -0
  201. package/dist/apps/www/settings/filters/[uid].d.ts +8 -0
  202. package/dist/apps/www/settings/filters/_mailFilterRuleConfig.d.ts +11 -0
  203. package/dist/apps/www/settings/filters/index.d.ts +4 -0
  204. package/dist/apps/www/settings/filters/new/index.d.ts +4 -0
  205. package/dist/apps/www/settings/labels/index.d.ts +4 -0
  206. package/dist/apps/www/settings/privacy/index.d.ts +4 -0
  207. package/dist/apps/www/settings/read-receipts/index.d.ts +4 -0
  208. package/dist/apps/www/settings/sharing/index.d.ts +4 -0
  209. package/dist/apps/www/settings/signatures/[uid].d.ts +8 -0
  210. package/dist/apps/www/settings/signatures/index.d.ts +4 -0
  211. package/dist/apps/www/settings/signatures/new/index.d.ts +4 -0
  212. package/dist/apps/www/settings/signatures/signatureDefaults.d.ts +10 -0
  213. package/dist/apps/www/tasks/index.d.ts +3 -0
  214. package/package.json +3 -3
  215. package/apps/shared/components/booking/AvailabilityEditor.tsx +0 -118
  216. package/apps/www/settings/booking-types/[uid].tsx +0 -306
  217. package/apps/www/settings/booking-types/index.tsx +0 -98
  218. package/apps/www/settings/booking-types/new/index.tsx +0 -212
  219. package/apps/www/settings/focused-inbox/index.tsx +0 -150
  220. package/dist/apps/shared/components/booking/AvailabilityEditor.js +0 -45
  221. package/dist/apps/www/settings/booking-types/[uid].js +0 -134
  222. package/dist/apps/www/settings/booking-types/index.js +0 -29
  223. package/dist/apps/www/settings/booking-types/new/index.js +0 -86
  224. 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
+ }
@@ -0,0 +1,73 @@
1
+ import React, { PropsWithChildren } from "react";
2
+ import { DraftThreading } from "@rapidmx/react-shared/mail/mailApi.js";
3
+ export interface ComposeSession {
4
+ id: string;
5
+ /** The initial sending mailbox - absent means "the caller's own mailbox" (see `ComposeWindow`). */
6
+ mailboxUid?: string;
7
+ initialTo?: string;
8
+ initialCc?: string;
9
+ initialSubject?: string;
10
+ /** Pre-built HTML (already includes its own quote-attribution wrapper — see `composeQuoting.ts`)
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. */
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;
18
+ /** Which of a signature's two "default" flags to resolve against — `"new"` (the default) uses
19
+ * `isDefaultForNewMessages`, `"reply_forward"` uses `isDefaultForReplyForward`. */
20
+ signatureContext: "new" | "reply_forward";
21
+ /** See `OpenComposeInput.suppressSigning`'s own doc comment. */
22
+ suppressSigning?: boolean;
23
+ minimized: boolean;
24
+ }
25
+ export interface OpenComposeInput {
26
+ /** The sending mailbox to start with. Omit for a fresh message (defaults to the caller's own mailbox);
27
+ * a reply/forward passes the original message's mailbox so a shared mailbox's mail replies from it.
28
+ * Either way the user can change it via the compose window's From field. */
29
+ mailboxUid?: string;
30
+ /** Prefills the To field — e.g. Contacts' "Email" toolbar action, or Reply/Reply All/Forward. */
31
+ to?: string;
32
+ /** Prefills the Cc field and reveals the Cc/Bcc row — Reply All only. */
33
+ cc?: string;
34
+ /** Prefills the Subject field — Reply/Reply All/Forward. */
35
+ subject?: string;
36
+ /** See `ComposeSession.initialQuotedHtml`'s own doc comment. */
37
+ quotedHtml?: string;
38
+ /** See `ComposeSession.signatureContext`'s own doc comment. Defaults to `"new"` — every existing
39
+ * caller (Contacts' "Email" action, the folder-sidebar "Compose" button) is a fresh compose. */
40
+ signatureContext?: "new" | "reply_forward";
41
+ /** `true` when the caller (`MessageDetailPane.tsx`'s Reply/Reply All) has already determined, via
42
+ * `composeSecurity.ts`'s `isLikelyMailingList()`, that the message being replied to came from a
43
+ * mailing list — a list that appends a footer after signing invalidates the signature (spec's own
44
+ * "Mailing lists" note), so the new compose window defaults its Sign toggle off rather than on.
45
+ * The user can still turn it back on manually; this only changes the *default*. */
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;
56
+ }
57
+ export interface ComposeContextValue {
58
+ /** Opens a new compose window, stacked alongside any already open (Gmail allows several at once). */
59
+ openCompose: (input: OpenComposeInput) => void;
60
+ }
61
+ /** Opens the floating Compose window from anywhere inside `AppShell` (any of the four webmail apps). */
62
+ export declare function useCompose(): ComposeContextValue;
63
+ /**
64
+ * Owns every currently-open Compose window and renders them stacked bottom-right, Gmail-style — see
65
+ * `ComposeWindow`'s own doc comment for why this replaced the old dedicated `/compose` page. Mounted
66
+ * once in `AppShell`, so every webmail app (Mail/Calendar/Contacts/Tasks) shares the same instance:
67
+ * opening Compose from Contacts' "Email" action, for instance, overlays the window on top of whatever
68
+ * app is currently showing, exactly like opening it from Mail's own sidebar button.
69
+ */
70
+ export default function ComposeProvider({ children, userUid, trusted }: PropsWithChildren<{
71
+ userUid?: string;
72
+ trusted?: boolean;
73
+ }>): React.JSX.Element;
@@ -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,
@@ -0,0 +1,15 @@
1
+ import React from "react";
2
+ import type { Editor } from "@tiptap/react";
3
+ export interface ComposeToolbarProps {
4
+ /** `null` before the editor has mounted client-side (see `RichTextEditor`'s `immediatelyRender: false`
5
+ * doc comment) — every button is disabled in that state rather than the toolbar rendering nothing, so
6
+ * its layout doesn't shift the instant the editor becomes ready. */
7
+ editor: Editor | null;
8
+ /** Uploads `file` as an attachment on the current draft and resolves to a URL the browser can
9
+ * preview it at while composing — see `BaseMailComposeRoute.rewriteInlineImageSources()`'s doc
10
+ * comment for why that's not the final URL the message actually ships with. Resolves to `null`
11
+ * (rather than rejecting) on failure — `ComposeWindow`'s own implementation is responsible for
12
+ * surfacing the error itself, so a failed upload here just quietly doesn't insert an image. */
13
+ onUploadImage: (file: File) => Promise<string | null>;
14
+ }
15
+ export default function ComposeToolbar({ editor, onUploadImage }: ComposeToolbarProps): React.JSX.Element;
@@ -0,0 +1,37 @@
1
+ import React from "react";
2
+ import type { ComposeSession } from "./ComposeContext.js";
3
+ export interface ComposeWindowProps {
4
+ session: ComposeSession;
5
+ onClose: () => void;
6
+ onToggleMinimize: () => void;
7
+ /** The signed-in user - identifies their own ("primary") mailbox, the default From when the session
8
+ * doesn't name a mailbox. */
9
+ userUid?: string;
10
+ /** The caller holds a trusted (admin) role - every mailbox is writable, so no per-mailbox access checks. */
11
+ trusted?: boolean;
12
+ /** How long (ms) after the last edit the draft is autosaved. Defaults to `DEFAULT_AUTOSAVE_DELAY_MS`;
13
+ * only overridden by tests. */
14
+ autosaveDelayMs?: number;
15
+ /** Backoff (ms) between automatic retries of a failed mailbox/encryption-policy load, one entry per retry.
16
+ * Defaults to `DEFAULT_CRYPTO_RETRY_DELAYS_MS`; only overridden by tests. */
17
+ cryptoRetryDelaysMs?: number[];
18
+ }
19
+ /** Debounce between the last edit and the draft autosave. */
20
+ export declare const DEFAULT_AUTOSAVE_DELAY_MS = 2000;
21
+ /** Automatic retries of a failed mailbox/encryption-policy load; after the last one only "Retry" tries again. */
22
+ export declare const DEFAULT_CRYPTO_RETRY_DELAYS_MS: number[];
23
+ /**
24
+ * A floating, Gmail-style compose window — overlays whatever app is currently showing instead of
25
+ * navigating to a dedicated page (see `ComposeContext.tsx`'s doc comment for why: a full-page compose
26
+ * form meant losing your place in the inbox/calendar/contacts view behind it, and every navigation to
27
+ * `/compose` re-ran this whole app's full mailbox/folder resolution waterfall from scratch). Owns its
28
+ * own draft lifecycle end to end (unlike the old page, which read the Drafts folder from `MailShell`'s
29
+ * context — this window can be opened from apps that never mount `MailShell` at all, e.g. Contacts'
30
+ * "Email" action, so it resolves its own Drafts folder from just a `mailboxUid`).
31
+ *
32
+ * Below the `md` breakpoint, a non-minimized session instead renders as a full-screen sheet (there's no
33
+ * room for a floating window, and the click-drag resize handles below become meaningless full-screen) —
34
+ * see `ComposeContext.tsx`'s own doc comment for how that interacts with several sessions being open at
35
+ * once.
36
+ */
37
+ export default function ComposeWindow({ session, onClose, onToggleMinimize, userUid, trusted, autosaveDelayMs, cryptoRetryDelaysMs, }: ComposeWindowProps): React.JSX.Element;
@@ -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,14 @@
1
+ import React, { RefObject } from "react";
2
+ export interface EmojiPickerProps {
3
+ anchorRef: RefObject<HTMLElement | null>;
4
+ onSelect: (native: string) => void;
5
+ onClose: () => void;
6
+ }
7
+ /**
8
+ * "Insert emoji" popup — a scrollable grid of every standard unicode emoji, grouped by category (see
9
+ * `emojiData.ts`). Deliberately no search box, unlike `GifPicker`'s — the user's own request only
10
+ * asked for a browsable grid here, and 8 category headers over ~1900 emojis is already a reasonably
11
+ * navigable amount without one. Rendered via `PopoverPortal` — see that component's own doc comment
12
+ * on why (escaping the toolbar's `overflow-hidden` ancestors).
13
+ */
14
+ export default function EmojiPicker({ anchorRef, onSelect, onClose }: EmojiPickerProps): React.JSX.Element;
@@ -0,0 +1,13 @@
1
+ import React, { RefObject } from "react";
2
+ export interface GifPickerProps {
3
+ anchorRef: RefObject<HTMLElement | null>;
4
+ onSelect: (url: string) => void;
5
+ onClose: () => void;
6
+ }
7
+ /** "Insert GIF" popup — a search box plus a scrollable grid of Giphy thumbnails (trending by default,
8
+ * live-searched as the user types). Selecting one inserts its full-resolution URL into the message
9
+ * body — see `ComposeToolbar.tsx`'s own doc comment on why that's a plain `<img>` reference rather
10
+ * than an uploaded attachment (unlike "Insert image"): a GIF is already hosted at a stable public
11
+ * URL, so there's nothing to upload. Rendered via `PopoverPortal` — see that component's own doc
12
+ * comment on why (escaping the toolbar's `overflow-hidden` ancestors). */
13
+ export default function GifPicker({ anchorRef, onSelect, onClose }: GifPickerProps): React.JSX.Element;
@@ -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;