@rapidmx/web-client 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. package/README.md +192 -88
  2. package/apps/shared/auth/accountUrl.ts +9 -0
  3. package/apps/shared/auth/adminAccess.ts +99 -0
  4. package/apps/shared/components/admin/elevation.ts +61 -0
  5. package/apps/shared/components/admin/layout/AdminShell.tsx +73 -7
  6. package/apps/shared/components/admin/settings/BrandingForm.tsx +2 -1
  7. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +87 -24
  8. package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
  9. package/apps/shared/components/contacts/ContactsToolbar.tsx +125 -115
  10. package/apps/shared/components/contacts/layout/ContactsShell.tsx +15 -3
  11. package/apps/shared/components/layout/AppShell.tsx +411 -288
  12. package/apps/shared/components/layout/BrandingChrome.tsx +3 -2
  13. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
  14. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -126
  15. package/apps/shared/components/layout/UserMenu.tsx +303 -150
  16. package/apps/shared/components/mail/ConversationList.tsx +269 -253
  17. package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
  18. package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
  19. package/apps/shared/components/mail/MailAddress.tsx +88 -0
  20. package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
  21. package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1383
  22. package/apps/shared/components/mail/NewMailToasts.tsx +141 -0
  23. package/apps/shared/components/mail/compose/ComposeContext.tsx +279 -160
  24. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
  25. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1842 -1700
  26. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
  27. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
  28. package/apps/shared/components/mail/compose/SendFailureAlert.tsx +48 -0
  29. package/apps/shared/components/mail/compose/composePerf.ts +46 -0
  30. package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
  31. package/apps/shared/components/mail/layout/MailShell.tsx +476 -445
  32. package/apps/shared/components/mail/unreadStyle.tsx +75 -0
  33. package/apps/shared/components/settings/layout/SettingsShell.tsx +252 -240
  34. package/apps/shared/components/tasks/layout/TasksShell.tsx +15 -3
  35. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
  36. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
  37. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
  38. package/apps/shared/keyboard/dispatch.ts +124 -0
  39. package/apps/shared/keyboard/format.ts +89 -0
  40. package/apps/shared/keyboard/keymap.ts +114 -0
  41. package/apps/shared/keyboard/match.ts +44 -0
  42. package/apps/shared/keyboard/parse.ts +136 -0
  43. package/apps/shared/keyboard/platform.ts +34 -0
  44. package/apps/shared/keyboard/registry.ts +65 -0
  45. package/apps/shared/keyboard/targets.ts +79 -0
  46. package/apps/shared/keyboard/useShortcut.ts +50 -0
  47. package/apps/shared/keyboard/useShortcutProps.ts +17 -0
  48. package/apps/shared/mail/folderCounts.ts +302 -0
  49. package/apps/shared/mail/listSnapshots.ts +87 -0
  50. package/apps/shared/mail/mergeFirstPage.ts +47 -0
  51. package/apps/shared/mail/messageReadState.ts +85 -0
  52. package/apps/shared/mail/newMailNotifications.ts +183 -0
  53. package/apps/shared/mail/useMailConnection.ts +150 -0
  54. package/apps/shared/mail/useMailLiveUpdates.ts +232 -0
  55. package/apps/shared/mail/useMarkMessageRead.ts +47 -0
  56. package/apps/shared/mail/useNewMailNotifications.ts +163 -0
  57. package/apps/shared/mail/useUnreadTitle.ts +42 -0
  58. package/apps/shared/navigation/AppRouter.tsx +300 -0
  59. package/apps/shared/navigation/appHrefs.ts +23 -0
  60. package/apps/shared/navigation/frameContext.tsx +35 -0
  61. package/apps/shared/navigation/idle.ts +45 -0
  62. package/apps/shared/navigation/routerContext.tsx +83 -0
  63. package/apps/shared/navigation/routes.ts +72 -0
  64. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
  65. package/apps/shared/styles/app.css +28 -10
  66. package/apps/www/_routedPage.tsx +24 -0
  67. package/apps/www/_routes.ts +35 -0
  68. package/apps/www/calendar/index.tsx +55 -19
  69. package/apps/www/contacts/[uid].tsx +112 -107
  70. package/apps/www/contacts/index.tsx +584 -567
  71. package/apps/www/index.tsx +2280 -1854
  72. package/apps/www/messages/[uid].tsx +106 -101
  73. package/apps/www/settings/auto-reply/index.tsx +4 -1
  74. package/apps/www/settings/encryption/index.tsx +1252 -1249
  75. package/apps/www/settings/filters/[uid].tsx +4 -1
  76. package/apps/www/settings/filters/index.tsx +102 -99
  77. package/apps/www/settings/filters/new/index.tsx +138 -133
  78. package/apps/www/settings/labels/index.tsx +204 -201
  79. package/apps/www/settings/privacy/index.tsx +4 -1
  80. package/apps/www/settings/read-receipts/index.tsx +4 -1
  81. package/apps/www/settings/sharing/index.tsx +277 -274
  82. package/apps/www/settings/signatures/[uid].tsx +170 -167
  83. package/apps/www/settings/signatures/index.tsx +88 -85
  84. package/apps/www/settings/signatures/new/index.tsx +134 -129
  85. package/apps/www/tasks/index.tsx +18 -2
  86. package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
  87. package/dist/apps/shared/auth/accountUrl.js +8 -0
  88. package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
  89. package/dist/apps/shared/auth/adminAccess.js +89 -0
  90. package/dist/apps/shared/components/admin/elevation.d.ts +24 -0
  91. package/dist/apps/shared/components/admin/elevation.js +56 -0
  92. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +12 -4
  93. package/dist/apps/shared/components/admin/layout/AdminShell.js +49 -6
  94. package/dist/apps/shared/components/admin/settings/BrandingForm.js +1 -1
  95. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +31 -13
  96. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
  97. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
  98. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  99. package/dist/apps/shared/components/contacts/ContactsToolbar.js +7 -4
  100. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
  101. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +15 -5
  102. package/dist/apps/shared/components/layout/AppShell.d.ts +29 -4
  103. package/dist/apps/shared/components/layout/AppShell.js +73 -14
  104. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +3 -2
  105. package/dist/apps/shared/components/layout/BrandingChrome.js +3 -2
  106. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
  107. package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +5 -2
  108. package/dist/apps/shared/components/layout/MailboxProvisioning.js +45 -8
  109. package/dist/apps/shared/components/layout/UserMenu.d.ts +28 -9
  110. package/dist/apps/shared/components/layout/UserMenu.js +87 -14
  111. package/dist/apps/shared/components/mail/ConversationList.js +9 -13
  112. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
  113. package/dist/apps/shared/components/mail/ConversationThreadPane.js +53 -28
  114. package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
  115. package/dist/apps/shared/components/mail/LazyReadingPane.js +82 -0
  116. package/dist/apps/shared/components/mail/MailAddress.d.ts +27 -0
  117. package/dist/apps/shared/components/mail/MailAddress.js +39 -0
  118. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +4 -1
  119. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -3
  120. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +7 -1
  121. package/dist/apps/shared/components/mail/MessageDetailPane.js +87 -36
  122. package/dist/apps/shared/components/mail/NewMailToasts.d.ts +19 -0
  123. package/dist/apps/shared/components/mail/NewMailToasts.js +55 -0
  124. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +30 -0
  125. package/dist/apps/shared/components/mail/compose/ComposeContext.js +71 -5
  126. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
  127. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +1 -1
  128. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +122 -27
  129. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
  130. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
  131. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
  132. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
  133. package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +14 -0
  134. package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +11 -0
  135. package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
  136. package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
  137. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
  138. package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
  139. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +19 -4
  140. package/dist/apps/shared/components/mail/layout/MailShell.js +85 -85
  141. package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
  142. package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
  143. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
  144. package/dist/apps/shared/components/settings/layout/SettingsShell.js +15 -5
  145. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
  146. package/dist/apps/shared/components/tasks/layout/TasksShell.js +15 -5
  147. package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
  148. package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
  149. package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
  150. package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
  151. package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
  152. package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
  153. package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
  154. package/dist/apps/shared/keyboard/dispatch.js +109 -0
  155. package/dist/apps/shared/keyboard/format.d.ts +12 -0
  156. package/dist/apps/shared/keyboard/format.js +74 -0
  157. package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
  158. package/dist/apps/shared/keyboard/keymap.js +84 -0
  159. package/dist/apps/shared/keyboard/match.d.ts +20 -0
  160. package/dist/apps/shared/keyboard/match.js +31 -0
  161. package/dist/apps/shared/keyboard/parse.d.ts +31 -0
  162. package/dist/apps/shared/keyboard/parse.js +107 -0
  163. package/dist/apps/shared/keyboard/platform.d.ts +15 -0
  164. package/dist/apps/shared/keyboard/platform.js +22 -0
  165. package/dist/apps/shared/keyboard/registry.d.ts +39 -0
  166. package/dist/apps/shared/keyboard/registry.js +33 -0
  167. package/dist/apps/shared/keyboard/targets.d.ts +14 -0
  168. package/dist/apps/shared/keyboard/targets.js +67 -0
  169. package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
  170. package/dist/apps/shared/keyboard/useShortcut.js +35 -0
  171. package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
  172. package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
  173. package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
  174. package/dist/apps/shared/mail/folderCounts.js +212 -0
  175. package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
  176. package/dist/apps/shared/mail/listSnapshots.js +43 -0
  177. package/dist/apps/shared/mail/mergeFirstPage.d.ts +23 -0
  178. package/dist/apps/shared/mail/mergeFirstPage.js +30 -0
  179. package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
  180. package/dist/apps/shared/mail/messageReadState.js +63 -0
  181. package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
  182. package/dist/apps/shared/mail/newMailNotifications.js +138 -0
  183. package/dist/apps/shared/mail/useMailConnection.d.ts +55 -0
  184. package/dist/apps/shared/mail/useMailConnection.js +96 -0
  185. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +55 -0
  186. package/dist/apps/shared/mail/useMailLiveUpdates.js +182 -0
  187. package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
  188. package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
  189. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +41 -0
  190. package/dist/apps/shared/mail/useNewMailNotifications.js +110 -0
  191. package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
  192. package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
  193. package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
  194. package/dist/apps/shared/navigation/AppRouter.js +242 -0
  195. package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
  196. package/dist/apps/shared/navigation/appHrefs.js +20 -0
  197. package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
  198. package/dist/apps/shared/navigation/frameContext.js +24 -0
  199. package/dist/apps/shared/navigation/idle.d.ts +14 -0
  200. package/dist/apps/shared/navigation/idle.js +43 -0
  201. package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
  202. package/dist/apps/shared/navigation/routerContext.js +56 -0
  203. package/dist/apps/shared/navigation/routes.d.ts +32 -0
  204. package/dist/apps/shared/navigation/routes.js +37 -0
  205. package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
  206. package/dist/apps/shared/styles/app.css +28 -10
  207. package/dist/apps/www/_routedPage.d.ts +12 -0
  208. package/dist/apps/www/_routedPage.js +19 -0
  209. package/dist/apps/www/_routes.d.ts +11 -0
  210. package/dist/apps/www/_routes.js +29 -0
  211. package/dist/apps/www/calendar/index.d.ts +2 -2
  212. package/dist/apps/www/calendar/index.js +39 -8
  213. package/dist/apps/www/contacts/[uid].d.ts +3 -9
  214. package/dist/apps/www/contacts/[uid].js +6 -2
  215. package/dist/apps/www/contacts/index.d.ts +2 -2
  216. package/dist/apps/www/contacts/index.js +17 -4
  217. package/dist/apps/www/index.d.ts +2 -2
  218. package/dist/apps/www/index.js +361 -34
  219. package/dist/apps/www/messages/[uid].d.ts +3 -7
  220. package/dist/apps/www/messages/[uid].js +7 -4
  221. package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
  222. package/dist/apps/www/settings/auto-reply/index.js +3 -1
  223. package/dist/apps/www/settings/encryption/index.d.ts +2 -2
  224. package/dist/apps/www/settings/encryption/index.js +3 -1
  225. package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
  226. package/dist/apps/www/settings/filters/[uid].js +3 -1
  227. package/dist/apps/www/settings/filters/index.d.ts +2 -2
  228. package/dist/apps/www/settings/filters/index.js +3 -1
  229. package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
  230. package/dist/apps/www/settings/filters/new/index.js +6 -2
  231. package/dist/apps/www/settings/labels/index.d.ts +2 -2
  232. package/dist/apps/www/settings/labels/index.js +3 -1
  233. package/dist/apps/www/settings/privacy/index.d.ts +2 -2
  234. package/dist/apps/www/settings/privacy/index.js +3 -1
  235. package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
  236. package/dist/apps/www/settings/read-receipts/index.js +3 -1
  237. package/dist/apps/www/settings/sharing/index.d.ts +2 -2
  238. package/dist/apps/www/settings/sharing/index.js +3 -1
  239. package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
  240. package/dist/apps/www/settings/signatures/[uid].js +3 -1
  241. package/dist/apps/www/settings/signatures/index.d.ts +2 -2
  242. package/dist/apps/www/settings/signatures/index.js +3 -1
  243. package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
  244. package/dist/apps/www/settings/signatures/new/index.js +6 -2
  245. package/dist/apps/www/tasks/index.d.ts +2 -2
  246. package/dist/apps/www/tasks/index.js +15 -3
  247. package/package.json +2 -2
@@ -1,5 +1,13 @@
1
1
  import React, { PropsWithChildren } from "react";
2
2
  import { DraftThreading } from "@rapidmx/react-shared/mail/mailApi.js";
3
+ /**
4
+ * Starts downloading the compose window's code without opening one - on hover/focus of a Compose or Reply button and when
5
+ * the browser is idle after the inbox has loaded - so that clicking it finds the chunk already in memory. Safe to call
6
+ * any number of times, and never rejects: a failed download is retried by the click itself.
7
+ */
8
+ export declare function prefetchComposeWindow(): void;
9
+ /** Test seam: forgets the loaded compose window chunk, as a fresh page load would. */
10
+ export declare function resetComposeWindowLoader(): void;
3
11
  export interface ComposeSession {
4
12
  id: string;
5
13
  /** The initial sending mailbox - absent means "the caller's own mailbox" (see `ComposeWindow`). */
@@ -20,8 +28,21 @@ export interface ComposeSession {
20
28
  signatureContext: "new" | "reply_forward";
21
29
  /** See `OpenComposeInput.suppressSigning`'s own doc comment. */
22
30
  suppressSigning?: boolean;
31
+ /** The window opened before its quoted original was known (see `OpenComposeInput.pending`): the body waits for it. */
32
+ quotePending?: boolean;
33
+ /** What `OpenComposeInput.pending` resolved with, once it has - see `ComposeLateInput`. */
34
+ late?: ComposeLateInput;
23
35
  minimized: boolean;
24
36
  }
37
+ /** What a reply or forward could only work out after its window was already open (see `OpenComposeInput.pending`). */
38
+ export interface ComposeLateInput {
39
+ /** The quoted original - see `OpenComposeInput.quotedHtml`. */
40
+ quotedHtml?: string;
41
+ /** A better To than the one the window opened with - applied only if the To field is still exactly what it opened with. */
42
+ to?: string;
43
+ /** A better Cc, under the same rule as `to`. */
44
+ cc?: string;
45
+ }
25
46
  export interface OpenComposeInput {
26
47
  /** The sending mailbox to start with. Omit for a fresh message (defaults to the caller's own mailbox);
27
48
  * a reply/forward passes the original message's mailbox so a shared mailbox's mail replies from it.
@@ -53,6 +74,15 @@ export interface OpenComposeInput {
53
74
  * `In-Reply-To`/`References` at all and every mail system - the sender's own Sent Items included - files it
54
75
  * as a new conversation rather than part of the thread. Absent for a fresh compose, which starts one. */
55
76
  threading?: DraftThreading;
77
+ /**
78
+ * Something the window shouldn't wait for before appearing: a reply or forward opens at once from what is already in
79
+ * memory (the message's own subject and sender), and this settles with what needed the network - the original's body
80
+ * to quote, and for Reply All the recipients its headers name. The compose window shows meanwhile, its body area
81
+ * waiting, and fills the body in when this resolves; `to`/`cc` replace the opened-with values only if the user hasn't
82
+ * touched those fields. Must never reject (resolve `undefined` for "nothing more"); when set, `quotedHtml` is ignored
83
+ * until it resolves.
84
+ */
85
+ pending?: Promise<ComposeLateInput | undefined>;
56
86
  }
57
87
  export interface ComposeContextValue {
58
88
  /** Opens a new compose window, stacked alongside any already open (Gmail allows several at once). */
@@ -3,9 +3,67 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { createContext, useContext, useMemo, useState } from "react";
6
+ import { createContext, useContext, useEffect, useMemo, useState } from "react";
7
7
  import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
8
- import ComposeWindow from "./ComposeWindow.js";
8
+ import { markComposePhase } from "./composePerf.js";
9
+ import ComposeWindowPlaceholder from "./ComposeWindowPlaceholder.js";
10
+ let loadedComposeWindow;
11
+ let loadingComposeWindow;
12
+ /**
13
+ * The compose window - with the rich-text editor (TipTap/ProseMirror), the recipient inputs and the send pipeline - is
14
+ * the largest piece of the mail app and most page loads never open it, so it is a separate chunk, loaded here once. Not
15
+ * `React.lazy()`: a chunk that failed to download (offline, a deploy that replaced it) must be retryable, and `lazy()` keeps
16
+ * the rejection for good.
17
+ */
18
+ function loadComposeWindow() {
19
+ loadingComposeWindow ?? (loadingComposeWindow = import("./ComposeWindow.js").then((module) => (loadedComposeWindow = module.default), (err) => {
20
+ loadingComposeWindow = undefined;
21
+ throw err;
22
+ }));
23
+ return loadingComposeWindow;
24
+ }
25
+ /**
26
+ * Starts downloading the compose window's code without opening one - on hover/focus of a Compose or Reply button and when
27
+ * the browser is idle after the inbox has loaded - so that clicking it finds the chunk already in memory. Safe to call
28
+ * any number of times, and never rejects: a failed download is retried by the click itself.
29
+ */
30
+ export function prefetchComposeWindow() {
31
+ loadComposeWindow().catch(() => undefined);
32
+ }
33
+ /** Test seam: forgets the loaded compose window chunk, as a fresh page load would. */
34
+ export function resetComposeWindowLoader() {
35
+ loadedComposeWindow = undefined;
36
+ loadingComposeWindow = undefined;
37
+ }
38
+ /** The compose window component once its chunk is in, else `undefined` (while loading, or `failed`), and a way to retry. */
39
+ function useComposeWindowComponent(wanted) {
40
+ const [loaded, setComponent] = useState();
41
+ // Also read straight from the module: a prefetch that finished after this provider mounted has no state update to
42
+ // announce it, and the first click must not flash the placeholder for a chunk that is already here.
43
+ const Component = loaded ?? loadedComposeWindow;
44
+ const [failed, setFailed] = useState(false);
45
+ const [attempt, setAttempt] = useState(0);
46
+ useEffect(() => {
47
+ if (!wanted || Component) {
48
+ return;
49
+ }
50
+ let cancelled = false;
51
+ setFailed(false);
52
+ loadComposeWindow().then((component) => {
53
+ if (!cancelled) {
54
+ setComponent(() => component);
55
+ }
56
+ }, () => {
57
+ if (!cancelled) {
58
+ setFailed(true);
59
+ }
60
+ });
61
+ return () => {
62
+ cancelled = true;
63
+ };
64
+ }, [wanted, Component, attempt]);
65
+ return { Component, failed, retry: () => setAttempt((n) => n + 1) };
66
+ }
9
67
  const ComposeContext = createContext({ openCompose: () => undefined });
10
68
  /** Opens the floating Compose window from anywhere inside `AppShell` (any of the four webmail apps). */
11
69
  export function useCompose() {
@@ -21,11 +79,14 @@ export function useCompose() {
21
79
  export default function ComposeProvider({ children, userUid, trusted }) {
22
80
  const [sessions, setSessions] = useState([]);
23
81
  const isMobile = useIsMobile();
24
- function openCompose({ mailboxUid, to, cc, subject, quotedHtml, signatureContext = "new", suppressSigning, encrypt, threading, }) {
82
+ const { Component: ComposeWindow, failed, retry: retryLoad } = useComposeWindowComponent(sessions.length > 0);
83
+ function openCompose({ mailboxUid, to, cc, subject, quotedHtml, signatureContext = "new", suppressSigning, encrypt, threading, pending, }) {
84
+ const id = crypto.randomUUID();
85
+ markComposePhase(id, "click");
25
86
  setSessions((prev) => [
26
87
  ...prev,
27
88
  {
28
- id: crypto.randomUUID(),
89
+ id,
29
90
  mailboxUid,
30
91
  initialTo: to,
31
92
  initialCc: cc,
@@ -35,9 +96,14 @@ export default function ComposeProvider({ children, userUid, trusted }) {
35
96
  threading,
36
97
  signatureContext,
37
98
  suppressSigning,
99
+ quotePending: !!pending,
38
100
  minimized: false,
39
101
  },
40
102
  ]);
103
+ // A window closed before this settles is simply not in the list any more - nothing to update.
104
+ void pending
105
+ ?.catch(() => undefined)
106
+ .then((late) => setSessions((prev) => prev.map((s) => s.id === id ? { ...s, quotePending: false, late, initialQuotedHtml: late?.quotedHtml ?? s.initialQuotedHtml } : s)));
41
107
  }
42
108
  function closeCompose(id) {
43
109
  setSessions((prev) => prev.filter((s) => s.id !== id));
@@ -56,6 +122,6 @@ export default function ComposeProvider({ children, userUid, trusted }) {
56
122
  const lastNonMinimizedId = isMobile ? [...sessions].reverse().find((s) => !s.minimized)?.id : undefined;
57
123
  return (_jsxs(ComposeContext.Provider, { value: value, children: [children, sessions.length > 0 && (_jsx("div", { className: "fixed bottom-0 right-6 flex items-end gap-3 z-50", children: sessions.map((session) => {
58
124
  const hidden = isMobile && !session.minimized && session.id !== lastNonMinimizedId;
59
- return (_jsx("div", { hidden: hidden, className: hidden ? "hidden" : "contents", children: _jsx(ComposeWindow, { session: session, userUid: userUid, trusted: trusted, onClose: () => closeCompose(session.id), onToggleMinimize: () => toggleMinimize(session.id) }) }, session.id));
125
+ return (_jsx("div", { hidden: hidden, className: hidden ? "hidden" : "contents", children: ComposeWindow ? (_jsx(ComposeWindow, { session: session, userUid: userUid, trusted: trusted, onClose: () => closeCompose(session.id), onToggleMinimize: () => toggleMinimize(session.id) })) : (_jsx(ComposeWindowPlaceholder, { session: session, failed: failed, onRetry: retryLoad, onClose: () => closeCompose(session.id), onToggleMinimize: () => toggleMinimize(session.id) })) }, session.id));
60
126
  }) }))] }));
61
127
  }
@@ -3,10 +3,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { useRef, useState } from "react";
6
+ import { Suspense, lazy, useRef, useState } from "react";
7
7
  import { BsArrowClockwise, BsArrowCounterclockwise, BsEraser, BsHighlighter, BsImage, BsIndent, BsJustify, BsLink45Deg, BsListOl, BsListUl, BsPaletteFill, BsTable, BsTextCenter, BsTextLeft, BsTextRight, BsTypeBold, BsTypeItalic, BsTypeStrikethrough, BsTypeUnderline, BsUnindent, } from "react-icons/bs";
8
- import EmojiPicker from "./EmojiPicker.js";
9
8
  import GifPicker from "./GifPicker.js";
9
+ /** Loaded when the picker is first opened: the emoji list is a half-megabyte JSON file that nothing else needs. */
10
+ const EmojiPicker = lazy(() => import("./EmojiPicker.js"));
10
11
  const FONT_FAMILIES = [
11
12
  { label: "Default", value: "" },
12
13
  { label: "Sans Serif", value: "Arial, Helvetica, sans-serif" },
@@ -84,5 +85,5 @@ export default function ComposeToolbar({ editor, onUploadImage }) {
84
85
  ? ed.chain().focus().setFontFamily(e.target.value).run()
85
86
  : ed.chain().focus().unsetFontFamily().run()), className: "text-xs py-1 px-1.5 border border-border rounded-sm bg-surface disabled:opacity-40", children: FONT_FAMILIES.map((f) => (_jsx("option", { value: f.value, children: f.label }, f.label))) }), _jsx("select", { "aria-label": "Font size", disabled: disabled, value: editor?.getAttributes("textStyle").fontSize ?? "", onChange: (e) => run((ed) => e.target.value
86
87
  ? ed.chain().focus().setFontSize(e.target.value).run()
87
- : ed.chain().focus().unsetFontSize().run()), className: "text-xs py-1 px-1.5 border border-border rounded-sm bg-surface disabled:opacity-40", children: FONT_SIZES.map((f) => (_jsx("option", { value: f.value, children: f.label }, f.label))) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Bold", icon: BsTypeBold, disabled: disabled, active: editor?.isActive("bold"), onClick: () => run((e) => e.chain().focus().toggleBold().run()) }), _jsx(ToolbarButton, { label: "Italic", icon: BsTypeItalic, disabled: disabled, active: editor?.isActive("italic"), onClick: () => run((e) => e.chain().focus().toggleItalic().run()) }), _jsx(ToolbarButton, { label: "Underline", icon: BsTypeUnderline, disabled: disabled, active: editor?.isActive("underline"), onClick: () => run((e) => e.chain().focus().toggleUnderline().run()) }), _jsx(ToolbarButton, { label: "Strikethrough", icon: BsTypeStrikethrough, disabled: disabled, active: editor?.isActive("strike"), onClick: () => run((e) => e.chain().focus().toggleStrike().run()) }), _jsx(Divider, {}), _jsxs("label", { className: "w-8 h-8 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed", children: [_jsx(BsPaletteFill, { size: 16, "aria-hidden": "true" }), _jsx("span", { className: "sr-only", children: "Text color" }), _jsx("input", { type: "color", disabled: disabled, value: editor?.getAttributes("textStyle").color ?? "#000000", onChange: (e) => run((ed) => ed.chain().focus().setColor(e.target.value).run()), className: "sr-only" })] }), _jsx(ToolbarButton, { label: "Highlight", icon: BsHighlighter, disabled: disabled, active: editor?.isActive("highlight"), onClick: () => run((e) => e.chain().focus().toggleHighlight().run()) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Align left", icon: BsTextLeft, disabled: disabled, active: editor?.isActive({ textAlign: "left" }), onClick: () => run((e) => e.chain().focus().setTextAlign("left").run()) }), _jsx(ToolbarButton, { label: "Align center", icon: BsTextCenter, disabled: disabled, active: editor?.isActive({ textAlign: "center" }), onClick: () => run((e) => e.chain().focus().setTextAlign("center").run()) }), _jsx(ToolbarButton, { label: "Align right", icon: BsTextRight, disabled: disabled, active: editor?.isActive({ textAlign: "right" }), onClick: () => run((e) => e.chain().focus().setTextAlign("right").run()) }), _jsx(ToolbarButton, { label: "Justify", icon: BsJustify, disabled: disabled, active: editor?.isActive({ textAlign: "justify" }), onClick: () => run((e) => e.chain().focus().setTextAlign("justify").run()) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Bulleted list", icon: BsListUl, disabled: disabled, active: editor?.isActive("bulletList"), onClick: () => run((e) => e.chain().focus().toggleBulletList().run()) }), _jsx(ToolbarButton, { label: "Numbered list", icon: BsListOl, disabled: disabled, active: editor?.isActive("orderedList"), onClick: () => run((e) => e.chain().focus().toggleOrderedList().run()) }), _jsx(ToolbarButton, { label: "Decrease indent", icon: BsUnindent, disabled: disabled, onClick: () => run((e) => e.chain().focus().liftListItem("listItem").run()) }), _jsx(ToolbarButton, { label: "Increase indent", icon: BsIndent, disabled: disabled, onClick: () => run((e) => e.chain().focus().sinkListItem("listItem").run()) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Insert link", icon: BsLink45Deg, disabled: disabled, active: editor?.isActive("link"), onClick: openLinkPrompt }), _jsx(ToolbarButton, { label: "Insert image", icon: BsImage, disabled: disabled, onClick: () => imageInputRef.current?.click() }), _jsx("input", { ref: imageInputRef, type: "file", accept: "image/*", onChange: handleImageFileSelected, className: "sr-only", "aria-label": "Insert image file" }), _jsx(ToolbarButton, { label: "Insert table", icon: BsTable, disabled: disabled, onClick: () => run((e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()) }), _jsx("button", { ref: emojiButtonRef, type: "button", "aria-label": "Insert emoji", title: "Insert emoji", disabled: disabled, onClick: () => setOpenPopup((p) => (p === "emoji" ? null : "emoji")), className: "w-8 h-8 flex items-center justify-center text-base rounded-sm disabled:opacity-40 disabled:cursor-not-allowed text-text-muted hover:bg-surface-alt hover:text-text", children: "\uD83D\uDE00" }), openPopup === "emoji" && (_jsx(EmojiPicker, { anchorRef: emojiButtonRef, onSelect: handleInsertEmoji, onClose: () => setOpenPopup(null) })), _jsx("button", { ref: gifButtonRef, type: "button", "aria-label": "Insert GIF", title: "Insert GIF", disabled: disabled, onClick: () => setOpenPopup((p) => (p === "gif" ? null : "gif")), className: "w-8 h-8 flex items-center justify-center text-[10px] font-extrabold tracking-tight rounded-sm disabled:opacity-40 disabled:cursor-not-allowed text-text-muted hover:bg-surface-alt hover:text-text", children: "GIF" }), openPopup === "gif" && _jsx(GifPicker, { anchorRef: gifButtonRef, onSelect: handleInsertGif, onClose: () => setOpenPopup(null) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Clear formatting", icon: BsEraser, disabled: disabled, onClick: () => run((e) => e.chain().focus().unsetAllMarks().clearNodes().run()) }), _jsx(ToolbarButton, { label: "Undo", icon: BsArrowCounterclockwise, disabled: disabled || !editor?.can().undo(), onClick: () => run((e) => e.chain().focus().undo().run()) }), _jsx(ToolbarButton, { label: "Redo", icon: BsArrowClockwise, disabled: disabled || !editor?.can().redo(), onClick: () => run((e) => e.chain().focus().redo().run()) })] }), linkPrompt] }));
88
+ : ed.chain().focus().unsetFontSize().run()), className: "text-xs py-1 px-1.5 border border-border rounded-sm bg-surface disabled:opacity-40", children: FONT_SIZES.map((f) => (_jsx("option", { value: f.value, children: f.label }, f.label))) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Bold", icon: BsTypeBold, disabled: disabled, active: editor?.isActive("bold"), onClick: () => run((e) => e.chain().focus().toggleBold().run()) }), _jsx(ToolbarButton, { label: "Italic", icon: BsTypeItalic, disabled: disabled, active: editor?.isActive("italic"), onClick: () => run((e) => e.chain().focus().toggleItalic().run()) }), _jsx(ToolbarButton, { label: "Underline", icon: BsTypeUnderline, disabled: disabled, active: editor?.isActive("underline"), onClick: () => run((e) => e.chain().focus().toggleUnderline().run()) }), _jsx(ToolbarButton, { label: "Strikethrough", icon: BsTypeStrikethrough, disabled: disabled, active: editor?.isActive("strike"), onClick: () => run((e) => e.chain().focus().toggleStrike().run()) }), _jsx(Divider, {}), _jsxs("label", { className: "w-8 h-8 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed", children: [_jsx(BsPaletteFill, { size: 16, "aria-hidden": "true" }), _jsx("span", { className: "sr-only", children: "Text color" }), _jsx("input", { type: "color", disabled: disabled, value: editor?.getAttributes("textStyle").color ?? "#000000", onChange: (e) => run((ed) => ed.chain().focus().setColor(e.target.value).run()), className: "sr-only" })] }), _jsx(ToolbarButton, { label: "Highlight", icon: BsHighlighter, disabled: disabled, active: editor?.isActive("highlight"), onClick: () => run((e) => e.chain().focus().toggleHighlight().run()) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Align left", icon: BsTextLeft, disabled: disabled, active: editor?.isActive({ textAlign: "left" }), onClick: () => run((e) => e.chain().focus().setTextAlign("left").run()) }), _jsx(ToolbarButton, { label: "Align center", icon: BsTextCenter, disabled: disabled, active: editor?.isActive({ textAlign: "center" }), onClick: () => run((e) => e.chain().focus().setTextAlign("center").run()) }), _jsx(ToolbarButton, { label: "Align right", icon: BsTextRight, disabled: disabled, active: editor?.isActive({ textAlign: "right" }), onClick: () => run((e) => e.chain().focus().setTextAlign("right").run()) }), _jsx(ToolbarButton, { label: "Justify", icon: BsJustify, disabled: disabled, active: editor?.isActive({ textAlign: "justify" }), onClick: () => run((e) => e.chain().focus().setTextAlign("justify").run()) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Bulleted list", icon: BsListUl, disabled: disabled, active: editor?.isActive("bulletList"), onClick: () => run((e) => e.chain().focus().toggleBulletList().run()) }), _jsx(ToolbarButton, { label: "Numbered list", icon: BsListOl, disabled: disabled, active: editor?.isActive("orderedList"), onClick: () => run((e) => e.chain().focus().toggleOrderedList().run()) }), _jsx(ToolbarButton, { label: "Decrease indent", icon: BsUnindent, disabled: disabled, onClick: () => run((e) => e.chain().focus().liftListItem("listItem").run()) }), _jsx(ToolbarButton, { label: "Increase indent", icon: BsIndent, disabled: disabled, onClick: () => run((e) => e.chain().focus().sinkListItem("listItem").run()) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Insert link", icon: BsLink45Deg, disabled: disabled, active: editor?.isActive("link"), onClick: openLinkPrompt }), _jsx(ToolbarButton, { label: "Insert image", icon: BsImage, disabled: disabled, onClick: () => imageInputRef.current?.click() }), _jsx("input", { ref: imageInputRef, type: "file", accept: "image/*", onChange: handleImageFileSelected, className: "sr-only", "aria-label": "Insert image file" }), _jsx(ToolbarButton, { label: "Insert table", icon: BsTable, disabled: disabled, onClick: () => run((e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()) }), _jsx("button", { ref: emojiButtonRef, type: "button", "aria-label": "Insert emoji", title: "Insert emoji", disabled: disabled, onClick: () => setOpenPopup((p) => (p === "emoji" ? null : "emoji")), className: "w-8 h-8 flex items-center justify-center text-base rounded-sm disabled:opacity-40 disabled:cursor-not-allowed text-text-muted hover:bg-surface-alt hover:text-text", children: "\uD83D\uDE00" }), openPopup === "emoji" && (_jsx(Suspense, { fallback: null, children: _jsx(EmojiPicker, { anchorRef: emojiButtonRef, onSelect: handleInsertEmoji, onClose: () => setOpenPopup(null) }) })), _jsx("button", { ref: gifButtonRef, type: "button", "aria-label": "Insert GIF", title: "Insert GIF", disabled: disabled, onClick: () => setOpenPopup((p) => (p === "gif" ? null : "gif")), className: "w-8 h-8 flex items-center justify-center text-[10px] font-extrabold tracking-tight rounded-sm disabled:opacity-40 disabled:cursor-not-allowed text-text-muted hover:bg-surface-alt hover:text-text", children: "GIF" }), openPopup === "gif" && _jsx(GifPicker, { anchorRef: gifButtonRef, onSelect: handleInsertGif, onClose: () => setOpenPopup(null) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Clear formatting", icon: BsEraser, disabled: disabled, onClick: () => run((e) => e.chain().focus().unsetAllMarks().clearNodes().run()) }), _jsx(ToolbarButton, { label: "Undo", icon: BsArrowCounterclockwise, disabled: disabled || !editor?.can().undo(), onClick: () => run((e) => e.chain().focus().undo().run()) }), _jsx(ToolbarButton, { label: "Redo", icon: BsArrowClockwise, disabled: disabled || !editor?.can().redo(), onClick: () => run((e) => e.chain().focus().redo().run()) })] }), linkPrompt] }));
88
89
  }
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import type { ComposeSession } from "./ComposeContext.js";
2
+ import { type ComposeSession } from "./ComposeContext.js";
3
3
  export interface ComposeWindowProps {
4
4
  session: ComposeSession;
5
5
  onClose: () => void;
@@ -8,6 +8,7 @@ import { HiOutlineArrowsPointingIn, HiOutlineArrowsPointingOut, HiOutlineChevron
8
8
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
9
  import { assembleDraft, assembleDraftRaw, attachmentContentUrl, createDraft, deleteMessage, getMailbox, getMessage, listFolders, listMailboxes, sendMessage, setMessageRequestReceipt, uploadAttachment, } from "@rapidmx/react-shared/mail/mailApi.js";
10
10
  import { listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
11
+ import { describeSendFailure } from "@rapidmx/react-shared/mail/sendFailure.js";
11
12
  import { buildComposeBodyHtml } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
12
13
  import { peekMailboxWritability, useMailboxWritability } from "../writableMailboxes.js";
13
14
  import { decideMessageEncryption, resolveRecipientEncryption } from "@rapidmx/react-shared/crypto/composeSecurity.js";
@@ -15,13 +16,19 @@ import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/cryp
15
16
  import { findActivePublicKey, getEncryptionPolicy, lookupKeys } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
16
17
  import { useUnlockPrompt } from "../../layout/UnlockPromptProvider.js";
17
18
  import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
18
- import { applyBaselineOuterHeaders, assembleOutboundMime, buildEncryptedMessage, buildSignedOnlyMessage } from "@rapidmx/react-shared/crypto/smimeMessage.js";
19
19
  import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
20
+ import { useCompose } from "./ComposeContext.js";
21
+ import { ariaKeyShortcuts, withHint } from "../../../keyboard/format.js";
22
+ import { SHORTCUTS } from "../../../keyboard/keymap.js";
23
+ import { useKeyEnvironment } from "../../../keyboard/ShortcutProvider.js";
24
+ import { useShortcut } from "../../../keyboard/useShortcut.js";
20
25
  import { isSigningOut, registerComposeFlush } from "./composeFlushRegistry.js";
26
+ import { markComposePhase } from "./composePerf.js";
21
27
  import RecipientInput from "./RecipientInput.js";
22
28
  import { parseRecipientList } from "./recipients.js";
23
29
  import RichTextEditor from "./RichTextEditor.js";
24
30
  import ScheduleSendPicker from "./ScheduleSendPicker.js";
31
+ import SendFailureAlert from "./SendFailureAlert.js";
25
32
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
26
33
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
27
34
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
@@ -59,6 +66,10 @@ const ENCRYPTED_CLOSE_MESSAGE = "Encrypted messages aren't saved as drafts, so c
59
66
  const CHECKING_CLOSE_MESSAGE = "This message may be encrypted, and that's still being checked, so it can't be saved as a draft yet. Keep editing and close again in a moment, or discard it.";
60
67
  const CRYPTO_UNAVAILABLE_MESSAGE = "Your encryption settings couldn't be checked, so this draft isn't being saved - it might be a message that must be encrypted.";
61
68
  const NO_RECIPIENTS_CLOSE_MESSAGE = "This message may be encrypted once its recipients are known, so it isn't saved as a draft until you add them. Add a recipient and close again, or discard it.";
69
+ const SEND_FAILED_TITLE = "This message wasn't sent";
70
+ function sendFailedCloseMessage(reason) {
71
+ return `Sending it failed (${reason.replace(/\.$/, "")}). Closing keeps it as a draft, unsent. Keep editing to try again, or discard it.`;
72
+ }
62
73
  const FIELD_ROW = "flex items-center gap-2 px-3 py-1.5 border-b border-border";
63
74
  const FIELD_INPUT = "flex-1 min-w-0 text-sm bg-transparent outline-none";
64
75
  const MIN_WIDTH = 320;
@@ -74,8 +85,9 @@ const VIEWPORT_MARGIN = 24;
74
85
  function parseAddresses(value) {
75
86
  return parseRecipientList(value);
76
87
  }
77
- function HeaderButton({ label, onClick, icon: Icon, disabled, }) {
78
- 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 }) }));
88
+ function HeaderButton({ label, onClick, icon: Icon, disabled, shortcut, }) {
89
+ const env = useKeyEnvironment();
90
+ return (_jsx("button", { type: "button", "aria-label": label, title: shortcut ? withHint(label, shortcut, env) : label, "aria-keyshortcuts": shortcut ? ariaKeyShortcuts(shortcut, env) : undefined, onClick: onClick, disabled: disabled, className: "w-6 h-6 flex items-center justify-center rounded-sm text-white/80 hover:bg-white/15 hover:text-white disabled:opacity-40 disabled:cursor-not-allowed", children: _jsx(Icon, { size: 14 }) }));
79
91
  }
80
92
  /**
81
93
  * A floating, Gmail-style compose window — overlays whatever app is currently showing instead of
@@ -92,7 +104,15 @@ function HeaderButton({ label, onClick, icon: Icon, disabled, }) {
92
104
  * once.
93
105
  */
94
106
  export default function ComposeWindow({ session, onClose, onToggleMinimize, userUid, trusted, autosaveDelayMs = DEFAULT_AUTOSAVE_DELAY_MS, cryptoRetryDelaysMs = DEFAULT_CRYPTO_RETRY_DELAYS_MS, }) {
95
- const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, minimized } = session;
107
+ const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, minimized, quotePending, late } = session;
108
+ // What a reply worked out after this window opened (see `OpenComposeInput.pending`): better recipients, applied below
109
+ // only to fields still exactly as they opened - so these, not `initialTo`/`initialCc`, are what "untouched" is measured against.
110
+ const baselineTo = late?.to ?? initialTo;
111
+ const baselineCc = late?.cc ?? initialCc;
112
+ useEffect(() => {
113
+ markComposePhase(id, "shell");
114
+ markComposePhase(id, "chunk");
115
+ }, []);
96
116
  // The sending ("From") mailbox. A reply/forward session names the original message's mailbox; a fresh
97
117
  // compose leaves it unset and defaults to the caller's own mailbox once `listMailboxes()` resolves.
98
118
  // Everything mailbox-scoped below (Drafts folder, draft, signatures, crypto context) keys off this.
@@ -104,6 +124,8 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
104
124
  const [hasUploads, setHasUploads] = useState(false);
105
125
  const isMobile = useIsMobile();
106
126
  const { requestUnlock } = useUnlockPrompt();
127
+ const { openCompose } = useCompose();
128
+ const keyEnv = useKeyEnvironment();
107
129
  // Bumped after a successful on-demand unlock to force a re-render - `getUnlockedKeys()` below is a
108
130
  // plain read from a module-level store, not React state, so nothing else would pick up the change.
109
131
  const [, setUnlockRefresh] = useState(0);
@@ -126,6 +148,8 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
126
148
  const [attachments, setAttachments] = useState([]);
127
149
  const [attachError, setAttachError] = useState(null);
128
150
  const [sendError, setSendError] = useState(null);
151
+ // The technical facts behind `sendError` (per-recipient SMTP results, a transport error), when the server gave any.
152
+ const [sendDetails, setSendDetails] = useState([]);
129
153
  const [sending, setSending] = useState(false);
130
154
  const [requestReceipt, setRequestReceipt] = useState(false);
131
155
  const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
@@ -360,26 +384,41 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
360
384
  // non-blocking fetch in this codebase (e.g. `ConversationList`'s own child-message fetch) — no
361
385
  // signature is a completely legitimate outcome, so this falls back to just the quoted content
362
386
  // (if any) rather than surfacing an error over what's a cosmetic nicety.
387
+ //
388
+ // A reply or forward opens before its quoted original has been fetched (`quotePending`, see
389
+ // `OpenComposeInput.pending`). The body is seeded as soon as the signature is here, without the quote, so the editor is usable
390
+ // at once however slow the original is to come; the quote is added at the end of the editor's document when it arrives
391
+ // (`appendHtml` below) - the same place, under the signature, that a body seeded with it would have had it.
392
+ const [defaultSignature, setDefaultSignature] = useState();
363
393
  useEffect(() => {
364
- // Seeds the body once, from whichever mailbox is the sender when compose opens - switching From
365
- // later must not overwrite what the user has already written (the editor itself never re-syncs).
366
- if (!mailboxUid || contentReady) {
394
+ // Fetched once, for whichever mailbox is the sender when compose opens - switching From later must not
395
+ // overwrite what the user has already written (the editor itself never re-syncs).
396
+ if (!mailboxUid || contentReady || defaultSignature) {
367
397
  return;
368
398
  }
369
399
  listMailSignatures(mailboxUid)
370
400
  .then((signatures) => {
371
401
  const signature = signatures.find((s) => signatureContext === "new" ? s.isDefaultForNewMessages : s.isDefaultForReplyForward);
372
- const seeded = buildComposeBodyHtml(signature?.contentHtml, initialQuotedHtml);
373
- setHtml(seeded);
374
- setSeededHtml(seeded);
402
+ setDefaultSignature({ html: signature?.contentHtml });
375
403
  })
376
- .catch(() => {
377
- const seeded = buildComposeBodyHtml(undefined, initialQuotedHtml);
378
- setHtml(seeded);
379
- setSeededHtml(seeded);
380
- })
381
- .finally(() => setContentReady(true));
382
- }, [mailboxUid, signatureContext, initialQuotedHtml]);
404
+ .catch(() => setDefaultSignature({}));
405
+ }, [mailboxUid, signatureContext]);
406
+ const [seededWithoutQuote, setSeededWithoutQuote] = useState(false);
407
+ useEffect(() => {
408
+ if (contentReady || !defaultSignature) {
409
+ return;
410
+ }
411
+ const seeded = buildComposeBodyHtml(defaultSignature.html, quotePending ? undefined : initialQuotedHtml);
412
+ setHtml(seeded);
413
+ setSeededHtml(seeded);
414
+ setSeededWithoutQuote(!!quotePending);
415
+ setContentReady(true);
416
+ markComposePhase(id, "body");
417
+ }, [defaultSignature, quotePending, initialQuotedHtml]);
418
+ /** What the editor adds to the end of its document once the quote a reply was opened without has arrived. */
419
+ const lateQuoteHtml = seededWithoutQuote && !quotePending && initialQuotedHtml && defaultSignature
420
+ ? buildComposeBodyHtml(defaultSignature.html, initialQuotedHtml).slice(buildComposeBodyHtml(defaultSignature.html) === "" ? "<p></p>".length : buildComposeBodyHtml(defaultSignature.html).length)
421
+ : undefined;
383
422
  useEffect(() => {
384
423
  if (!mailboxUid || !draftsFolderUid || draft) {
385
424
  return;
@@ -397,6 +436,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
397
436
  return;
398
437
  }
399
438
  setDraft(created);
439
+ markComposePhase(id, "draft");
400
440
  // Only now that the replacement exists is it safe to discard the draft(s) a From switch
401
441
  // superseded - if creating this one had failed, the earlier draft is still there.
402
442
  for (const superseded of supersededDraftsRef.current.splice(0)) {
@@ -702,6 +742,9 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
702
742
  };
703
743
  const bodyContentType = 'text/html; charset="utf-8"';
704
744
  const signing = canSign ? { certDer: keys.unlocked.signingCertDer, privateKey: keys.unlocked.signingPrivateKey } : undefined;
745
+ // The S/MIME code (PKI.js and the ASN.1/X.509 libraries, over half a megabyte) is only needed here, to sign or
746
+ // encrypt - a plain message never loads it, and it is kept out of the compose window's own chunk.
747
+ const { applyBaselineOuterHeaders, assembleOutboundMime, buildEncryptedMessage, buildSignedOnlyMessage } = await import("@rapidmx/react-shared/crypto/smimeMessage.js");
705
748
  const outerHeaders = wantEncrypt ? applyBaselineOuterHeaders(protectedHeaders) : protectedHeaders;
706
749
  const mimePart = wantEncrypt
707
750
  ? await buildEncryptedMessage(bodyContentType, html, protectedHeaders, outerHeaders, recipientCertDers, signing)
@@ -736,6 +779,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
736
779
  setSending(true);
737
780
  sendingRef.current = true;
738
781
  setSendError(null);
782
+ setSendDetails([]);
739
783
  setSecurityBlock(null);
740
784
  setEncryptionBlocked(null);
741
785
  pendingSaveRef.current = false;
@@ -756,8 +800,10 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
756
800
  onClose();
757
801
  }
758
802
  catch (err) {
759
- const fallback = scheduledSendTimeIso ? "Could not schedule this message." : "Could not send this message.";
760
- setSendError(err instanceof ApiRequestError ? err.message : fallback);
803
+ // The window stays open with the draft exactly as it was; nothing here closes, discards or saves over it.
804
+ const failure = describeSendFailure(err, scheduledSendTimeIso ? "Could not schedule this message." : "Could not send this message.");
805
+ setSendError(failure.message);
806
+ setSendDetails(failure.lines);
761
807
  }
762
808
  finally {
763
809
  sendingRef.current = false;
@@ -922,11 +968,16 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
922
968
  * the same confirmation as a discard. A failed save keeps the window open with Discard / Keep editing.
923
969
  * Close and Discard are both disabled while a send is in progress: deleting the draft, or saving a
924
970
  * plaintext copy over the message the send just assembled, mid-send would lose or leak it. */
925
- async function handleClose() {
971
+ async function handleClose(sendFailureAcknowledged = false) {
926
972
  if (!hasUserContent) {
927
973
  void discardNow();
928
974
  return;
929
975
  }
976
+ if (sendError && !sendFailureAcknowledged) {
977
+ // Closing would keep the draft and say nothing, which reads as if it had been sent.
978
+ setClosePrompt({ title: SEND_FAILED_TITLE, message: sendFailedCloseMessage(sendError), closeAnyway: true });
979
+ return;
980
+ }
930
981
  if (encryptionDecided) {
931
982
  setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
932
983
  return;
@@ -1003,7 +1054,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1003
1054
  // Autosave. "User content" is anything beyond what compose opened with (prefilled recipients/subject,
1004
1055
  // seeded signature/quote) - an untouched window has nothing worth saving or confirming a discard of.
1005
1056
  const contentKey = JSON.stringify([to, cc, bcc, subject, html]);
1006
- const baselineKey = JSON.stringify([initialTo ?? "", initialCc ?? "", "", initialSubject ?? "", seededHtml ?? ""]);
1057
+ const baselineKey = JSON.stringify([baselineTo ?? "", baselineCc ?? "", "", initialSubject ?? "", seededHtml ?? ""]);
1007
1058
  const hasUserContent = contentKey !== baselineKey || hasUploads;
1008
1059
  // A message headed for encryption is never autosaved: the draft would store its plaintext server-side,
1009
1060
  // defeating end-to-end encryption. Until it's known not to be, it isn't either: while the mailbox (whose
@@ -1026,6 +1077,21 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1026
1077
  (encryptionPossible && currentAddresses.some((address) => exhaustedLookups[address] && !recipientStatuses[address]));
1027
1078
  const latestRef = useRef({ draft, to, cc, bcc, subject, html, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided });
1028
1079
  latestRef.current = { draft, to, cc, bcc, subject, html, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided };
1080
+ // Recipients a reply could only work out after this window opened (Reply All: whoever the original's headers name)
1081
+ // replace the ones it opened with - but only in a field the user hasn't touched, never over what they typed.
1082
+ useEffect(() => {
1083
+ if (!late) {
1084
+ return;
1085
+ }
1086
+ const current = latestRef.current;
1087
+ if (late.to !== undefined && current.to === (initialTo ?? "")) {
1088
+ setTo(late.to);
1089
+ }
1090
+ if (late.cc !== undefined && current.cc === (initialCc ?? "")) {
1091
+ setCc(late.cc);
1092
+ setShowCcBcc((shown) => shown || late.cc !== "");
1093
+ }
1094
+ }, [late]);
1029
1095
  useEffect(() => {
1030
1096
  const due = !!draft &&
1031
1097
  contentReady &&
@@ -1102,7 +1168,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1102
1168
  // Where the caret starts, once per session: a reply/forward in the body (at its top), a new message in To - or,
1103
1169
  // with To already filled in (e.g. Contacts' "Email"), in Subject. Minimizing unmounts the fields, so restoring a
1104
1170
  // window must not move the caret there again.
1105
- const isReplyOrForward = initialQuotedHtml !== undefined;
1171
+ const isReplyOrForward = initialQuotedHtml !== undefined || !!quotePending;
1106
1172
  const fieldsFocusedRef = useRef(false);
1107
1173
  const bodyFocusedRef = useRef(false);
1108
1174
  const focusToOnMount = !isReplyOrForward && !initialTo && !fieldsFocusedRef.current;
@@ -1111,7 +1177,16 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1111
1177
  // The editor serializes the seeded body its own way (see `RichTextEditor`'s `onInitialized`). While the body is
1112
1178
  // still exactly what compose seeded, that serialization becomes the baseline - so an untouched reply or signature
1113
1179
  // isn't mistaken for an edit (and autosaved) just because the editor was focused or clicked.
1180
+ /** The quote has been added: unless the reader has typed something already, that is still exactly what compose seeded. */
1181
+ function handleQuoteAppended(normalized) {
1182
+ const latest = latestRef.current;
1183
+ if (latest.html === latest.seededHtml) {
1184
+ setHtml(normalized);
1185
+ setSeededHtml(normalized);
1186
+ }
1187
+ }
1114
1188
  function handleEditorInitialized(normalized) {
1189
+ markComposePhase(id, "editor");
1115
1190
  const latest = latestRef.current;
1116
1191
  if (latest.html === latest.seededHtml && normalized !== latest.html) {
1117
1192
  setHtml(normalized);
@@ -1124,18 +1199,38 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1124
1199
  bodyFocusedRef.current || (bodyFocusedRef.current = contentReady);
1125
1200
  }
1126
1201
  }, [minimized, contentReady]);
1202
+ // Keyboard shortcuts, active while the focus is inside this window (`container`; the scope attribute on its root is what tells the
1203
+ // dispatcher the focus is in a compose window at all). They do what the Send and Close buttons and autosave do, under the same conditions -
1204
+ // a shortcut pressed while its button would be disabled is consumed and does nothing.
1205
+ const canSend = !!draft && !sending && !closing && cryptoContextReady;
1206
+ useShortcut(SHORTCUTS.compose.send, () => void (canSend && submit(false)), { container: windowRef });
1207
+ useShortcut(SHORTCUTS.compose.saveDraft, () => {
1208
+ // Autosave's own conditions: never a draft that must not be stored as plaintext, and nothing while a send is on the wire.
1209
+ if (draft && contentReady && cryptoContextReady && !sending && !closing && !autosaveSuppressed && hasUserContent) {
1210
+ void saveDraftNow();
1211
+ }
1212
+ }, { container: windowRef });
1213
+ useShortcut(SHORTCUTS.compose.close, () => {
1214
+ if (!sending && !closing) {
1215
+ void handleClose();
1216
+ }
1217
+ }, { container: windowRef });
1218
+ useShortcut(SHORTCUTS.compose.create, () => openCompose({ mailboxUid }), { container: windowRef });
1127
1219
  const title = subject.trim() || "New Message";
1128
1220
  const titleId = `compose-title-${id}`;
1129
- const discardModal = (_jsxs(Modal, { open: closePrompt !== null, onClose: () => setClosePrompt(null), title: closePrompt?.title ?? DISCARD_TITLE, children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: closePrompt?.message }), _jsxs("div", { className: "flex gap-3", children: [closePrompt?.retry && (_jsx(Button, { type: "button", onClick: retryCryptoContext, className: "!w-auto", children: "Retry" })), _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" })] })] }));
1221
+ const discardModal = (_jsxs(Modal, { open: closePrompt !== null, onClose: () => setClosePrompt(null), title: closePrompt?.title ?? DISCARD_TITLE, children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: closePrompt?.message }), _jsxs("div", { className: "flex gap-3", children: [closePrompt?.retry && (_jsx(Button, { type: "button", onClick: retryCryptoContext, className: "!w-auto", children: "Retry" })), closePrompt?.closeAnyway && (_jsx(Button, { type: "button", onClick: () => {
1222
+ setClosePrompt(null);
1223
+ void handleClose(true);
1224
+ }, disabled: sending || closing, className: "!w-auto", children: "Close, keep draft" })), _jsx(Button, { type: "button", onClick: () => void discardNow(), disabled: sending || closing, className: "!w-auto", children: "Discard" }), _jsx(Button, { type: "button", variant: "secondary", onClick: () => setClosePrompt(null), className: "!w-auto", children: "Keep editing" })] })] }));
1130
1225
  if (minimized) {
1131
- return (_jsxs(_Fragment, { children: [_jsx("div", { role: "dialog", "aria-label": title, className: "w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal", children: _jsxs("div", { className: "h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Restore", onClick: onToggleMinimize, icon: HiOutlineArrowsPointingOut }), _jsx(HeaderButton, { label: "Discard draft", onClick: handleDiscard, icon: HiOutlineXMark, disabled: sending || closing })] })] }) }), discardModal] }));
1226
+ return (_jsxs(_Fragment, { children: [_jsx("div", { role: "dialog", "aria-label": title, className: "w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal", children: _jsxs("div", { className: "h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { className: "text-sm font-medium truncate", children: title }), sendError && _jsx("span", { className: "shrink-0 text-xs font-bold uppercase tracking-wide", children: "Not sent" }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Restore", onClick: onToggleMinimize, icon: HiOutlineArrowsPointingOut }), _jsx(HeaderButton, { label: "Discard draft", onClick: handleDiscard, icon: HiOutlineXMark, disabled: sending || closing })] })] }) }), discardModal] }));
1132
1227
  }
1133
- return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: windowRef, role: "dialog", "aria-labelledby": titleId, style: !isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined, className: [
1228
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: windowRef, role: "dialog", "data-shortcut-scope": "compose", "aria-labelledby": titleId, style: !isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined, className: [
1134
1229
  "relative shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
1135
1230
  isMobile
1136
1231
  ? "fixed inset-0 w-full h-full rounded-none border-0"
1137
1232
  : ["border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
1138
- ].join(" "), children: [!isMobile && (_jsxs(_Fragment, { children: [_jsx("div", { onPointerDown: handleResizeStart("top"), "aria-hidden": "true", className: "absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("left"), "aria-hidden": "true", className: "absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("corner"), role: "separator", "aria-label": "Resize", className: "absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30" })] })), _jsxs("div", { className: "h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { id: titleId, className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus }), !isMobile && (_jsx(HeaderButton, { label: expanded ? "Collapse" : "Expand", onClick: toggleExpanded, icon: expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut })), _jsx(HeaderButton, { label: "Close", onClick: () => void handleClose(), icon: HiOutlineXMark, disabled: sending || closing })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError || discardError) && (_jsxs("div", { className: "px-3 pt-2", children: [folderError && _jsx(Alert, { children: folderError }), draftError && _jsx(Alert, { children: draftError }), sendError && _jsx(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)) })] })] })), (() => {
1233
+ ].join(" "), children: [!isMobile && (_jsxs(_Fragment, { children: [_jsx("div", { onPointerDown: handleResizeStart("top"), "aria-hidden": "true", className: "absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("left"), "aria-hidden": "true", className: "absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("corner"), role: "separator", "aria-label": "Resize", className: "absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30" })] })), _jsxs("div", { className: "h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { id: titleId, className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus }), !isMobile && (_jsx(HeaderButton, { label: expanded ? "Collapse" : "Expand", onClick: toggleExpanded, icon: expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut })), _jsx(HeaderButton, { label: "Close", onClick: () => void handleClose(), icon: HiOutlineXMark, disabled: sending || closing, shortcut: SHORTCUTS.compose.close })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError || discardError) && (_jsxs("div", { className: "px-3 pt-2 shrink-0 max-h-[45%] overflow-y-auto", children: [folderError && _jsx(Alert, { children: folderError }), draftError && _jsx(Alert, { children: draftError }), sendError && _jsx(SendFailureAlert, { message: sendError, lines: sendDetails }), attachError && _jsx(Alert, { children: attachError }), discardError && _jsx(Alert, { children: discardError })] })), cryptoCheckUnavailable && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: CRYPTO_UNAVAILABLE_MESSAGE }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: retryCryptoContext, children: "Retry" })] }) })), encryptionBlocked && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsxs("p", { className: "mb-2", children: ["This message can\u2019t be encrypted for everyone: ", encryptionBlocked.map((r) => r.address).join(", "), encryptionBlocked[0]?.prohibitedReason ? ` — ${encryptionBlocked[0].prohibitedReason}` : " has no encryption key on file", ". Remove ", encryptionBlocked.length > 1 ? "these recipients" : "this recipient", " from To/Cc/Bcc, or send the whole message in plaintext."] }), _jsx(Button, { type: "button", disabled: sending, onClick: () => void submit(true, blockedScheduleIso), children: "Send without encryption" })] }) })), securityBlock && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: securityBlock.message }), _jsx(Button, { type: "button", disabled: sending, onClick: () => void submit(true, blockedScheduleIso), children: securityBlock.overrideLabel })] }) })), fromOptions.length > 1 && (_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-from-${id}`, className: "text-xs text-text-muted shrink-0", children: "From" }), _jsx("select", { id: `compose-from-${id}`, className: `${FIELD_INPUT} disabled:opacity-55`, value: mailboxUid ?? "", disabled: hasUploads || sending, title: hasUploads ? "The sender can't be changed after adding attachments or images." : undefined, onChange: (e) => handleFromChange(e.target.value), children: fromOptions.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName ? `${mb.displayName} <${mb.primarySmtpAddress}>` : mb.primarySmtpAddress, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-to-${id}`, className: "text-xs text-text-muted shrink-0", children: "To" }), _jsx(RecipientInput, { id: `compose-to-${id}`, label: "To", value: to, mailboxUid: mailboxUid, onChange: setTo, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)), autoFocus: focusToOnMount }), !showCcBcc && (_jsx("button", { type: "button", onClick: () => setShowCcBcc(true), className: "text-xs text-text-muted hover:text-text shrink-0", children: "Cc Bcc" }))] }), showCcBcc && (_jsxs(_Fragment, { children: [_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-cc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Cc" }), _jsx(RecipientInput, { id: `compose-cc-${id}`, label: "Cc", value: cc, mailboxUid: mailboxUid, onChange: setCc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] }), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-bcc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Bcc" }), _jsx(RecipientInput, { id: `compose-bcc-${id}`, label: "Bcc", value: bcc, mailboxUid: mailboxUid, onChange: setBcc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] })] })), (() => {
1139
1234
  const knownRecipients = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]
1140
1235
  .map((r) => recipientStatuses[r.address])
1141
1236
  .filter((status) => !!status);
@@ -1143,5 +1238,5 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
1143
1238
  return null;
1144
1239
  }
1145
1240
  return (_jsx("ul", { className: "flex flex-wrap gap-1.5 px-3 pt-1.5", "aria-label": "Recipient encryption availability", children: knownRecipients.map((status) => (_jsxs("li", { className: `text-xs font-medium py-0.5 px-2 rounded-pill ${status.canEncrypt ? "bg-primary/10 text-primary-dark" : "bg-surface-alt text-text-muted"}`, children: [status.address, " ", status.canEncrypt ? "supports encryption" : "no encryption key found"] }, status.address))) }));
1146
- })(), _jsx("div", { className: FIELD_ROW, children: _jsx("input", { "aria-label": "Subject", type: "text", placeholder: "Subject", className: FIELD_INPUT, value: subject, autoFocus: focusSubjectOnMount, onChange: (e) => setSubject(e.target.value) }) }), _jsx("div", { className: "flex-1 min-h-0 p-2", children: contentReady && (_jsx(RichTextEditor, { value: html, onChange: setHtml, fill: true, onUploadImage: handleUploadImage, autoFocusStart: focusBodyOnMount, onInitialized: handleEditorInitialized })) }), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 px-3 pb-2", children: attachments.map((attachment) => (_jsx("li", { className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted", children: attachment.filename }, attachment.uid))) })), _jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: requestReceipt, onChange: (e) => setRequestReceipt(e.target.checked) }), "Request a read receipt"] }), needsUnlockForCrypto && (_jsx("div", { className: "px-3 pb-1", children: _jsxs("button", { type: "button", onClick: handleUnlockForCrypto, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to sign or encrypt this message"] }) })), unlockedKeys?.signingPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: signEnabled, onChange: (e) => setSignEnabled(e.target.checked) }), "Digitally sign this message"] })), unlockedKeys?.encryptionPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: encryptRequested, onChange: (e) => setEncryptRequested(e.target.checked) }), "Encrypt this message"] })), _jsxs("div", { className: "shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border", children: [_jsxs("div", { className: "flex items-center rounded-pill bg-primary text-white overflow-hidden", children: [_jsx("button", { type: "button", onClick: () => void submit(false), disabled: !draft || sending || closing || !cryptoContextReady, className: "py-1.5 pl-5 pr-3 font-semibold text-sm hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: sending ? "Sending…" : "Send" }), _jsx("button", { ref: scheduleButtonRef, type: "button", "aria-label": "Send later", "aria-haspopup": "true", "aria-expanded": schedulePickerOpen, disabled: !draft || sending || closing || !cryptoContextReady, onClick: () => setSchedulePickerOpen((o) => !o), className: "py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: _jsx(HiOutlineChevronDown, { size: 14 }) })] }), schedulePickerOpen && (_jsx(ScheduleSendPicker, { anchorRef: scheduleButtonRef, onClose: () => setSchedulePickerOpen(false), onSchedule: handleScheduleSend })), _jsxs("label", { "aria-label": "Attach files", title: "Attach files", className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed", children: [_jsx(HiOutlinePaperClip, { size: 18 }), _jsx("input", { type: "file", multiple: true, disabled: !draft, onChange: handleFilesSelected, className: "sr-only" })] }), _jsx("span", { role: "status", "aria-live": "polite", className: "ml-auto text-xs text-text-muted", children: SAVE_STATUS_LABEL[saveStatus] }), _jsx("button", { type: "button", "aria-label": "Discard draft", title: "Discard draft", onClick: handleDiscard, disabled: sending || closing, className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text disabled:opacity-40 disabled:cursor-not-allowed", children: _jsx(HiOutlineTrash, { size: 18 }) })] })] })] }), discardModal] }));
1241
+ })(), _jsx("div", { className: FIELD_ROW, children: _jsx("input", { "aria-label": "Subject", type: "text", placeholder: "Subject", className: FIELD_INPUT, value: subject, autoFocus: focusSubjectOnMount, onChange: (e) => setSubject(e.target.value) }) }), _jsx("div", { className: "flex-1 min-h-0 p-2", children: contentReady && (_jsx(RichTextEditor, { appendHtml: lateQuoteHtml, onAppended: handleQuoteAppended, value: html, onChange: setHtml, fill: true, onUploadImage: handleUploadImage, autoFocusStart: focusBodyOnMount, onInitialized: handleEditorInitialized })) }), quotePending && (_jsx("p", { role: "status", className: "px-3 pb-1 text-xs text-text-muted", children: "Loading the original message\u2026" })), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 px-3 pb-2", children: attachments.map((attachment) => (_jsx("li", { className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted", children: attachment.filename }, attachment.uid))) })), _jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: requestReceipt, onChange: (e) => setRequestReceipt(e.target.checked) }), "Request a read receipt"] }), needsUnlockForCrypto && (_jsx("div", { className: "px-3 pb-1", children: _jsxs("button", { type: "button", onClick: handleUnlockForCrypto, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to sign or encrypt this message"] }) })), unlockedKeys?.signingPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: signEnabled, onChange: (e) => setSignEnabled(e.target.checked) }), "Digitally sign this message"] })), unlockedKeys?.encryptionPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: encryptRequested, onChange: (e) => setEncryptRequested(e.target.checked) }), "Encrypt this message"] })), _jsxs("div", { className: "shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border", children: [_jsxs("div", { className: "flex items-center rounded-pill bg-primary text-white overflow-hidden", children: [_jsx("button", { type: "button", onClick: () => void submit(false), title: withHint("Send", SHORTCUTS.compose.send, keyEnv), "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.compose.send, keyEnv), disabled: !draft || sending || closing || !cryptoContextReady, className: "py-1.5 pl-5 pr-3 font-semibold text-sm hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: sending ? "Sending…" : "Send" }), _jsx("button", { ref: scheduleButtonRef, type: "button", "aria-label": "Send later", "aria-haspopup": "true", "aria-expanded": schedulePickerOpen, disabled: !draft || sending || closing || !cryptoContextReady, onClick: () => setSchedulePickerOpen((o) => !o), className: "py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: _jsx(HiOutlineChevronDown, { size: 14 }) })] }), schedulePickerOpen && (_jsx(ScheduleSendPicker, { anchorRef: scheduleButtonRef, onClose: () => setSchedulePickerOpen(false), onSchedule: handleScheduleSend })), _jsxs("label", { "aria-label": "Attach files", title: "Attach files", className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed", children: [_jsx(HiOutlinePaperClip, { size: 18 }), _jsx("input", { type: "file", multiple: true, disabled: !draft, onChange: handleFilesSelected, className: "sr-only" })] }), _jsx("span", { role: "status", "aria-live": "polite", className: "ml-auto text-xs text-text-muted", children: SAVE_STATUS_LABEL[saveStatus] }), _jsx("button", { type: "button", "aria-label": "Discard draft", title: "Discard draft", onClick: handleDiscard, disabled: sending || closing, className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text disabled:opacity-40 disabled:cursor-not-allowed", children: _jsx(HiOutlineTrash, { size: 18 }) })] })] })] }), discardModal] }));
1147
1242
  }
@@ -0,0 +1,19 @@
1
+ import React from "react";
2
+ import type { ComposeSession } from "./ComposeContext.js";
3
+ export interface ComposeWindowPlaceholderProps {
4
+ session: ComposeSession;
5
+ /** The compose window's code couldn't be downloaded. */
6
+ failed: boolean;
7
+ onRetry: () => void;
8
+ onClose: () => void;
9
+ onToggleMinimize: () => void;
10
+ }
11
+ /**
12
+ * What stands in for the compose window in the moment between clicking Compose/Reply/Forward and the window's code being
13
+ * loaded (already there when it was prefetched, so usually not seen at all): the window's own frame at its own size and
14
+ * place, its title and the recipients and subject the window will open with, all of which are known without the network -
15
+ * so the click is answered on the same frame. It is deliberately tiny and lives in the page's own chunk.
16
+ *
17
+ * Nothing here can be typed into yet, and there is no draft to save or discard, so Close just closes.
18
+ */
19
+ export default function ComposeWindowPlaceholder({ session, failed, onRetry, onClose, onToggleMinimize }: ComposeWindowPlaceholderProps): React.JSX.Element;