@rapidmx/web-client 0.9.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 (226) 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/settings/DomainDnsSetup.tsx +2 -1
  5. package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
  6. package/apps/shared/components/contacts/ContactsToolbar.tsx +125 -115
  7. package/apps/shared/components/contacts/layout/ContactsShell.tsx +15 -3
  8. package/apps/shared/components/layout/AppShell.tsx +411 -288
  9. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
  10. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -164
  11. package/apps/shared/components/layout/UserMenu.tsx +303 -185
  12. package/apps/shared/components/mail/ConversationList.tsx +269 -262
  13. package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
  14. package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
  15. package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
  16. package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1387
  17. package/apps/shared/components/mail/NewMailToasts.tsx +141 -0
  18. package/apps/shared/components/mail/compose/ComposeContext.tsx +279 -160
  19. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
  20. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1842 -1736
  21. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
  22. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
  23. package/apps/shared/components/mail/compose/composePerf.ts +46 -0
  24. package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
  25. package/apps/shared/components/mail/layout/MailShell.tsx +476 -472
  26. package/apps/shared/components/mail/unreadStyle.tsx +75 -0
  27. package/apps/shared/components/settings/layout/SettingsShell.tsx +252 -240
  28. package/apps/shared/components/tasks/layout/TasksShell.tsx +15 -3
  29. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
  30. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
  31. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
  32. package/apps/shared/keyboard/dispatch.ts +124 -0
  33. package/apps/shared/keyboard/format.ts +89 -0
  34. package/apps/shared/keyboard/keymap.ts +114 -0
  35. package/apps/shared/keyboard/match.ts +44 -0
  36. package/apps/shared/keyboard/parse.ts +136 -0
  37. package/apps/shared/keyboard/platform.ts +34 -0
  38. package/apps/shared/keyboard/registry.ts +65 -0
  39. package/apps/shared/keyboard/targets.ts +79 -0
  40. package/apps/shared/keyboard/useShortcut.ts +50 -0
  41. package/apps/shared/keyboard/useShortcutProps.ts +17 -0
  42. package/apps/shared/mail/folderCounts.ts +302 -0
  43. package/apps/shared/mail/listSnapshots.ts +87 -0
  44. package/apps/shared/mail/messageReadState.ts +85 -0
  45. package/apps/shared/mail/newMailNotifications.ts +183 -0
  46. package/apps/shared/mail/useMailConnection.ts +150 -0
  47. package/apps/shared/mail/useMailLiveUpdates.ts +232 -224
  48. package/apps/shared/mail/useMarkMessageRead.ts +47 -0
  49. package/apps/shared/mail/useNewMailNotifications.ts +163 -0
  50. package/apps/shared/mail/useUnreadTitle.ts +42 -0
  51. package/apps/shared/navigation/AppRouter.tsx +300 -0
  52. package/apps/shared/navigation/appHrefs.ts +23 -0
  53. package/apps/shared/navigation/frameContext.tsx +35 -0
  54. package/apps/shared/navigation/idle.ts +45 -0
  55. package/apps/shared/navigation/routerContext.tsx +83 -0
  56. package/apps/shared/navigation/routes.ts +72 -0
  57. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
  58. package/apps/shared/styles/app.css +28 -10
  59. package/apps/www/_routedPage.tsx +24 -0
  60. package/apps/www/_routes.ts +35 -0
  61. package/apps/www/calendar/index.tsx +55 -19
  62. package/apps/www/contacts/[uid].tsx +112 -107
  63. package/apps/www/contacts/index.tsx +584 -567
  64. package/apps/www/index.tsx +2280 -1917
  65. package/apps/www/messages/[uid].tsx +106 -101
  66. package/apps/www/settings/auto-reply/index.tsx +4 -1
  67. package/apps/www/settings/encryption/index.tsx +1252 -1249
  68. package/apps/www/settings/filters/[uid].tsx +4 -1
  69. package/apps/www/settings/filters/index.tsx +102 -99
  70. package/apps/www/settings/filters/new/index.tsx +138 -133
  71. package/apps/www/settings/labels/index.tsx +204 -201
  72. package/apps/www/settings/privacy/index.tsx +4 -1
  73. package/apps/www/settings/read-receipts/index.tsx +4 -1
  74. package/apps/www/settings/sharing/index.tsx +277 -274
  75. package/apps/www/settings/signatures/[uid].tsx +170 -167
  76. package/apps/www/settings/signatures/index.tsx +88 -85
  77. package/apps/www/settings/signatures/new/index.tsx +134 -129
  78. package/apps/www/tasks/index.tsx +18 -2
  79. package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
  80. package/dist/apps/shared/auth/accountUrl.js +8 -0
  81. package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
  82. package/dist/apps/shared/auth/adminAccess.js +89 -0
  83. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +1 -1
  84. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
  85. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
  86. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  87. package/dist/apps/shared/components/contacts/ContactsToolbar.js +7 -4
  88. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
  89. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +15 -5
  90. package/dist/apps/shared/components/layout/AppShell.d.ts +29 -4
  91. package/dist/apps/shared/components/layout/AppShell.js +73 -14
  92. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
  93. package/dist/apps/shared/components/layout/MailboxProvisioning.js +3 -1
  94. package/dist/apps/shared/components/layout/UserMenu.d.ts +19 -3
  95. package/dist/apps/shared/components/layout/UserMenu.js +52 -5
  96. package/dist/apps/shared/components/mail/ConversationList.js +4 -11
  97. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
  98. package/dist/apps/shared/components/mail/ConversationThreadPane.js +52 -27
  99. package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
  100. package/dist/apps/shared/components/mail/LazyReadingPane.js +82 -0
  101. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +4 -1
  102. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -3
  103. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +7 -1
  104. package/dist/apps/shared/components/mail/MessageDetailPane.js +83 -34
  105. package/dist/apps/shared/components/mail/NewMailToasts.d.ts +19 -0
  106. package/dist/apps/shared/components/mail/NewMailToasts.js +55 -0
  107. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +30 -0
  108. package/dist/apps/shared/components/mail/compose/ComposeContext.js +71 -5
  109. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
  110. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +1 -1
  111. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +98 -22
  112. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
  113. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
  114. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
  115. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
  116. package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
  117. package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
  118. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
  119. package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
  120. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +12 -4
  121. package/dist/apps/shared/components/mail/layout/MailShell.js +76 -96
  122. package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
  123. package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
  124. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
  125. package/dist/apps/shared/components/settings/layout/SettingsShell.js +15 -5
  126. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
  127. package/dist/apps/shared/components/tasks/layout/TasksShell.js +15 -5
  128. package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
  129. package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
  130. package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
  131. package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
  132. package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
  133. package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
  134. package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
  135. package/dist/apps/shared/keyboard/dispatch.js +109 -0
  136. package/dist/apps/shared/keyboard/format.d.ts +12 -0
  137. package/dist/apps/shared/keyboard/format.js +74 -0
  138. package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
  139. package/dist/apps/shared/keyboard/keymap.js +84 -0
  140. package/dist/apps/shared/keyboard/match.d.ts +20 -0
  141. package/dist/apps/shared/keyboard/match.js +31 -0
  142. package/dist/apps/shared/keyboard/parse.d.ts +31 -0
  143. package/dist/apps/shared/keyboard/parse.js +107 -0
  144. package/dist/apps/shared/keyboard/platform.d.ts +15 -0
  145. package/dist/apps/shared/keyboard/platform.js +22 -0
  146. package/dist/apps/shared/keyboard/registry.d.ts +39 -0
  147. package/dist/apps/shared/keyboard/registry.js +33 -0
  148. package/dist/apps/shared/keyboard/targets.d.ts +14 -0
  149. package/dist/apps/shared/keyboard/targets.js +67 -0
  150. package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
  151. package/dist/apps/shared/keyboard/useShortcut.js +35 -0
  152. package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
  153. package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
  154. package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
  155. package/dist/apps/shared/mail/folderCounts.js +212 -0
  156. package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
  157. package/dist/apps/shared/mail/listSnapshots.js +43 -0
  158. package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
  159. package/dist/apps/shared/mail/messageReadState.js +63 -0
  160. package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
  161. package/dist/apps/shared/mail/newMailNotifications.js +138 -0
  162. package/dist/apps/shared/mail/useMailConnection.d.ts +55 -0
  163. package/dist/apps/shared/mail/useMailConnection.js +96 -0
  164. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +13 -6
  165. package/dist/apps/shared/mail/useMailLiveUpdates.js +28 -26
  166. package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
  167. package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
  168. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +41 -0
  169. package/dist/apps/shared/mail/useNewMailNotifications.js +110 -0
  170. package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
  171. package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
  172. package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
  173. package/dist/apps/shared/navigation/AppRouter.js +242 -0
  174. package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
  175. package/dist/apps/shared/navigation/appHrefs.js +20 -0
  176. package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
  177. package/dist/apps/shared/navigation/frameContext.js +24 -0
  178. package/dist/apps/shared/navigation/idle.d.ts +14 -0
  179. package/dist/apps/shared/navigation/idle.js +43 -0
  180. package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
  181. package/dist/apps/shared/navigation/routerContext.js +56 -0
  182. package/dist/apps/shared/navigation/routes.d.ts +32 -0
  183. package/dist/apps/shared/navigation/routes.js +37 -0
  184. package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
  185. package/dist/apps/shared/styles/app.css +28 -10
  186. package/dist/apps/www/_routedPage.d.ts +12 -0
  187. package/dist/apps/www/_routedPage.js +19 -0
  188. package/dist/apps/www/_routes.d.ts +11 -0
  189. package/dist/apps/www/_routes.js +29 -0
  190. package/dist/apps/www/calendar/index.d.ts +2 -2
  191. package/dist/apps/www/calendar/index.js +39 -8
  192. package/dist/apps/www/contacts/[uid].d.ts +3 -9
  193. package/dist/apps/www/contacts/[uid].js +6 -2
  194. package/dist/apps/www/contacts/index.d.ts +2 -2
  195. package/dist/apps/www/contacts/index.js +17 -4
  196. package/dist/apps/www/index.d.ts +2 -2
  197. package/dist/apps/www/index.js +301 -35
  198. package/dist/apps/www/messages/[uid].d.ts +3 -7
  199. package/dist/apps/www/messages/[uid].js +7 -4
  200. package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
  201. package/dist/apps/www/settings/auto-reply/index.js +3 -1
  202. package/dist/apps/www/settings/encryption/index.d.ts +2 -2
  203. package/dist/apps/www/settings/encryption/index.js +3 -1
  204. package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
  205. package/dist/apps/www/settings/filters/[uid].js +3 -1
  206. package/dist/apps/www/settings/filters/index.d.ts +2 -2
  207. package/dist/apps/www/settings/filters/index.js +3 -1
  208. package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
  209. package/dist/apps/www/settings/filters/new/index.js +6 -2
  210. package/dist/apps/www/settings/labels/index.d.ts +2 -2
  211. package/dist/apps/www/settings/labels/index.js +3 -1
  212. package/dist/apps/www/settings/privacy/index.d.ts +2 -2
  213. package/dist/apps/www/settings/privacy/index.js +3 -1
  214. package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
  215. package/dist/apps/www/settings/read-receipts/index.js +3 -1
  216. package/dist/apps/www/settings/sharing/index.d.ts +2 -2
  217. package/dist/apps/www/settings/sharing/index.js +3 -1
  218. package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
  219. package/dist/apps/www/settings/signatures/[uid].js +3 -1
  220. package/dist/apps/www/settings/signatures/index.d.ts +2 -2
  221. package/dist/apps/www/settings/signatures/index.js +3 -1
  222. package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
  223. package/dist/apps/www/settings/signatures/new/index.js +6 -2
  224. package/dist/apps/www/tasks/index.d.ts +2 -2
  225. package/dist/apps/www/tasks/index.js +15 -3
  226. package/package.json +2 -2
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect } from "react";
7
+ import { HiOutlineMinus, HiOutlineXMark } from "react-icons/hi2";
8
+ import Skeleton from "@rapidmx/react-shared/components/feedback/Skeleton.js";
9
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
+ import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
12
+ import { markComposePhase } from "./composePerf.js";
13
+ /**
14
+ * What stands in for the compose window in the moment between clicking Compose/Reply/Forward and the window's code being
15
+ * loaded (already there when it was prefetched, so usually not seen at all): the window's own frame at its own size and
16
+ * place, its title and the recipients and subject the window will open with, all of which are known without the network -
17
+ * so the click is answered on the same frame. It is deliberately tiny and lives in the page's own chunk.
18
+ *
19
+ * Nothing here can be typed into yet, and there is no draft to save or discard, so Close just closes.
20
+ */
21
+ export default function ComposeWindowPlaceholder({ session, failed, onRetry, onClose, onToggleMinimize }) {
22
+ const isMobile = useIsMobile();
23
+ const title = session.initialSubject?.trim() || "New Message";
24
+ const titleId = `compose-title-${session.id}`;
25
+ useEffect(() => markComposePhase(session.id, "shell"), [session.id]);
26
+ if (session.minimized) {
27
+ return (_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: _jsx("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 }) }) }));
28
+ }
29
+ return (_jsxs("div", { role: "dialog", "aria-labelledby": titleId, "aria-busy": !failed, className: [
30
+ "relative shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
31
+ isMobile ? "fixed inset-0 w-full h-full rounded-none border-0" : "border-b-0 rounded-t-md w-[480px] h-[520px]",
32
+ ].join(" "), children: [_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("button", { type: "button", "aria-label": "Minimize", title: "Minimize", onClick: onToggleMinimize, className: "w-6 h-6 flex items-center justify-center rounded-sm text-white/80 hover:bg-white/15 hover:text-white", children: _jsx(HiOutlineMinus, { size: 14 }) }), _jsx("button", { type: "button", "aria-label": "Close", title: "Close", onClick: onClose, className: "w-6 h-6 flex items-center justify-center rounded-sm text-white/80 hover:bg-white/15 hover:text-white", children: _jsx(HiOutlineXMark, { size: 14 }) })] })] }), failed ? (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: "The compose window couldn\u2019t be loaded. Check your connection and try again." }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onRetry, children: "Retry" })] }) })) : (_jsxs("div", { role: "status", className: "flex-1 min-h-0 flex flex-col", children: [_jsx("span", { className: "sr-only", children: "Opening the compose window" }), _jsxs("div", { className: "flex items-center gap-2 px-3 py-1.5 border-b border-border text-sm", children: [_jsx("span", { className: "text-text-muted", children: "To" }), _jsx("span", { className: "truncate", children: session.initialTo })] }), _jsx("div", { className: "flex items-center gap-2 px-3 py-1.5 border-b border-border text-sm", children: _jsx("span", { className: "truncate", children: session.initialSubject || _jsx("span", { className: "text-text-muted", children: "Subject" }) }) }), _jsxs("div", { className: "flex-1 p-3 flex flex-col gap-2", "aria-hidden": "true", children: [_jsx(Skeleton, { height: "h-4" }), _jsx(Skeleton, { height: "h-4" }), _jsx(Skeleton, { height: "h-4" })] })] }))] }));
33
+ }
@@ -18,6 +18,14 @@ export interface RichTextEditorProps {
18
18
  * quote). That normalization happens on the editor's first transaction - even one that only moves the caret -
19
19
  * and isn't reported through `onChange`, so a caller can compare later edits against this instead of `value`. */
20
20
  onInitialized?: (value: string) => void;
21
+ /**
22
+ * HTML to add at the end of the document, once, as soon as there is some (it may be undefined when the editor is created and
23
+ * defined later). A reply opens its editor before the quoted original has been fetched and hands the quote in through this when
24
+ * it arrives - so whatever has been typed above stays, and the quote lands where it always did, under the signature.
25
+ */
26
+ appendHtml?: string;
27
+ /** Called with the document as the editor serializes it, right after `appendHtml` was added. */
28
+ onAppended?: (value: string) => void;
21
29
  }
22
30
  /**
23
31
  * A WYSIWYG rich-text editor for the Compose window's message body — replaces `MonacoHtmlEditor` (an HTML
@@ -40,4 +48,4 @@ export interface RichTextEditorProps {
40
48
  * sufficient here since TipTap (unlike Monaco) never touches the DOM at module-evaluation time, only when
41
49
  * an editor view actually mounts.
42
50
  */
43
- export default function RichTextEditor({ value, onChange, height, fill, onUploadImage, autoFocusStart, onInitialized, }: RichTextEditorProps): React.JSX.Element;
51
+ export default function RichTextEditor({ value, onChange, height, fill, onUploadImage, autoFocusStart, onInitialized, appendHtml, onAppended, }: RichTextEditorProps): React.JSX.Element;
@@ -3,7 +3,7 @@ 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 { useEffect, useRef, useState } from "react";
7
7
  import { EditorContent, useEditor } from "@tiptap/react";
8
8
  import StarterKit from "@tiptap/starter-kit";
9
9
  import { TextStyleKit } from "@tiptap/extension-text-style";
@@ -34,11 +34,14 @@ import ComposeToolbar from "./ComposeToolbar.js";
34
34
  * sufficient here since TipTap (unlike Monaco) never touches the DOM at module-evaluation time, only when
35
35
  * an editor view actually mounts.
36
36
  */
37
- export default function RichTextEditor({ value, onChange, height = "360px", fill = false, onUploadImage, autoFocusStart = false, onInitialized, }) {
37
+ export default function RichTextEditor({ value, onChange, height = "360px", fill = false, onUploadImage, autoFocusStart = false, onInitialized, appendHtml, onAppended, }) {
38
38
  const onChangeRef = useRef(onChange);
39
39
  onChangeRef.current = onChange;
40
40
  const onInitializedRef = useRef(onInitialized);
41
41
  onInitializedRef.current = onInitialized;
42
+ const onAppendedRef = useRef(onAppended);
43
+ onAppendedRef.current = onAppended;
44
+ const appendedRef = useRef(undefined);
42
45
  // Transactions before `create` (the autofocus, and the normalizing one below) aren't edits.
43
46
  const initializedRef = useRef(false);
44
47
  // Held for this editor's lifetime: TipTap focuses a tick after creating the editor, and a re-render passing a
@@ -57,6 +60,12 @@ export default function RichTextEditor({ value, onChange, height = "360px", fill
57
60
  content: value,
58
61
  autofocus,
59
62
  immediatelyRender: false,
63
+ editorProps: {
64
+ // Ctrl/Cmd+Enter is the compose window's Send. TipTap binds it to a hard break, and a key it handles never reaches the
65
+ // window's shortcut (which skips what a widget already took), so it inserted a line break and sent nothing. Returning true here
66
+ // stops ProseMirror handling it without preventing the event, which goes on to the shortcut. Shift+Enter is still the hard break.
67
+ handleDOMEvents: { keydown: (_view, event) => event.key === "Enter" && (event.ctrlKey || event.metaKey) },
68
+ },
60
69
  onCreate: ({ editor: created }) => {
61
70
  // An empty transaction runs the schema's append-transaction normalization now, before any edit.
62
71
  created.view.dispatch(created.state.tr);
@@ -69,6 +78,15 @@ export default function RichTextEditor({ value, onChange, height = "360px", fill
69
78
  }
70
79
  },
71
80
  });
81
+ // Added once, at the end, whatever the reader has typed meanwhile (the caret stays where it is).
82
+ useEffect(() => {
83
+ if (!editor || appendHtml === undefined || appendedRef.current === appendHtml) {
84
+ return;
85
+ }
86
+ appendedRef.current = appendHtml;
87
+ editor.commands.insertContentAt(editor.state.doc.content.size, appendHtml);
88
+ onAppendedRef.current?.(editor.getHTML());
89
+ }, [editor, appendHtml]);
72
90
  return (_jsxs("div", { className: ["border border-border rounded-sm overflow-hidden", fill ? "h-full flex flex-col" : ""].filter(Boolean).join(" "), children: [_jsx(ComposeToolbar, { editor: editor, onUploadImage: onUploadImage }), _jsx(EditorContent, { editor: editor, style: fill ? undefined : { height }, className: [
73
91
  "overflow-y-auto px-3 py-2 text-sm [&_.tiptap]:outline-none [&_.tiptap]:h-full",
74
92
  fill ? "flex-1 min-h-0" : "",
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `performance.mark()`/`measure()` around the phases between clicking Compose/Reply/Forward and the window being usable,
3
+ * for the browser's Performance panel (or `performance.getEntriesByType("measure")`). Marks are named
4
+ * `compose:<window id>:<phase>`; the measures are named `compose:<from>-><to>` and carry the window id in `detail`.
5
+ *
6
+ * Phases, in order: `click` (the handler ran), `shell` (the window's frame is on screen - the placeholder, or the real
7
+ * window once its chunk has loaded), `chunk` (the window's code has loaded and rendered), `body` (the seeded body -
8
+ * signature and quoted original - is ready), `editor` (the editor has mounted and can be typed into), `draft` (the server
9
+ * draft exists).
10
+ *
11
+ * On in development builds, and in a production build when `window.__RAPIDMX_PERF__` is set to `true` (from the console:
12
+ * `window.__RAPIDMX_PERF__ = true`). Off otherwise, when each call is a no-op.
13
+ */
14
+ export type ComposePhase = "click" | "shell" | "chunk" | "body" | "editor" | "draft";
15
+ /** Marks `phase` of window `id` once (later calls for the same phase are ignored), and measures it from `click`. */
16
+ export declare function markComposePhase(id: string, phase: ComposePhase): void;
@@ -0,0 +1,41 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * `performance.mark()`/`measure()` around the phases between clicking Compose/Reply/Forward and the window being usable,
7
+ * for the browser's Performance panel (or `performance.getEntriesByType("measure")`). Marks are named
8
+ * `compose:<window id>:<phase>`; the measures are named `compose:<from>-><to>` and carry the window id in `detail`.
9
+ *
10
+ * Phases, in order: `click` (the handler ran), `shell` (the window's frame is on screen - the placeholder, or the real
11
+ * window once its chunk has loaded), `chunk` (the window's code has loaded and rendered), `body` (the seeded body -
12
+ * signature and quoted original - is ready), `editor` (the editor has mounted and can be typed into), `draft` (the server
13
+ * draft exists).
14
+ *
15
+ * On in development builds, and in a production build when `window.__RAPIDMX_PERF__` is set to `true` (from the console:
16
+ * `window.__RAPIDMX_PERF__ = true`). Off otherwise, when each call is a no-op.
17
+ */
18
+ function enabled() {
19
+ if (typeof performance === "undefined" || typeof performance.mark !== "function") {
20
+ return false;
21
+ }
22
+ return import.meta.env?.DEV === true || globalThis.__RAPIDMX_PERF__ === true;
23
+ }
24
+ /** The compose sessions a `click` mark was made for, so the first mark of each phase is the only one that counts. */
25
+ const marked = new Set();
26
+ /** Marks `phase` of window `id` once (later calls for the same phase are ignored), and measures it from `click`. */
27
+ export function markComposePhase(id, phase) {
28
+ if (!enabled()) {
29
+ return;
30
+ }
31
+ const key = `compose:${id}:${phase}`;
32
+ if (marked.has(key)) {
33
+ return;
34
+ }
35
+ marked.add(key);
36
+ performance.mark(key);
37
+ const start = `compose:${id}:click`;
38
+ if (phase !== "click" && marked.has(start)) {
39
+ performance.measure(`compose:click->${phase}`, { start, end: key, detail: { id } });
40
+ }
41
+ }
@@ -9,6 +9,19 @@ export interface OriginalMessage {
9
9
  * be. Only asked for by Reply All (see `loadOriginalMessage()`). */
10
10
  recipients?: Recipient[];
11
11
  }
12
+ /** How long a reply waits for the original's body before it goes ahead with the preview - a slow or hung request must not
13
+ * leave the window waiting on it (or, before it opened at once, keep it from opening) indefinitely. */
14
+ export declare const QUOTE_FETCH_TIMEOUT_MS = 10000;
15
+ /** How long a fetched (or prefetched) body is remembered, so hovering Reply and then clicking it costs one request. */
16
+ export declare const QUOTE_CACHE_MS = 30000;
17
+ /**
18
+ * Starts fetching what a Reply/Forward of `message` will quote, without waiting for the click - called when the pointer or
19
+ * keyboard reaches a Reply/Reply All/Forward button - so the request is usually already answered when it is clicked.
20
+ * Nothing to fetch for an encrypted message (`loadOriginalMessage()` never asks the server for one's body).
21
+ */
22
+ export declare function prefetchOriginalMessage(message: Message): void;
23
+ /** Forgets every remembered body (the sign-out and test seam). */
24
+ export declare function clearOriginalMessageCache(): void;
12
25
  /**
13
26
  * Loads what a Reply/Reply All/Forward needs from `message`: the full body to quote - the same content
14
27
  * `MessageDetailPane` shows, never the truncated `bodyPreview` unless nothing else can be had - and, with
@@ -6,6 +6,66 @@ import { apiUrl } from "@rapidmx/react-shared/util/api.js";
6
6
  import { getMessageRawContent } from "@rapidmx/react-shared/mail/mailApi.js";
7
7
  import { decodeHeaderText, extractDisplayBody, parseMimeEntity } from "@rapidmx/react-shared/crypto/mime.js";
8
8
  import { parseRecipientList } from "./recipients.js";
9
+ /** How long a reply waits for the original's body before it goes ahead with the preview - a slow or hung request must not
10
+ * leave the window waiting on it (or, before it opened at once, keep it from opening) indefinitely. */
11
+ export const QUOTE_FETCH_TIMEOUT_MS = 10000;
12
+ /** How long a fetched (or prefetched) body is remembered, so hovering Reply and then clicking it costs one request. */
13
+ export const QUOTE_CACHE_MS = 30000;
14
+ const contentCache = new Map();
15
+ /**
16
+ * The server's sanitized HTML body of a message (`GET /mail/messages/:id/content`), or `undefined` when it has none (a
17
+ * `text/plain` message is answered with its preview instead) or the request failed or timed out. Never rejects. Remembered
18
+ * for `QUOTE_CACHE_MS` - except a failure, which is asked again the next time.
19
+ */
20
+ function fetchContentHtml(uid) {
21
+ const cached = contentCache.get(uid);
22
+ if (cached && Date.now() - cached.at < QUOTE_CACHE_MS) {
23
+ return cached.promise;
24
+ }
25
+ const controller = new AbortController();
26
+ const timer = setTimeout(() => controller.abort(), QUOTE_FETCH_TIMEOUT_MS);
27
+ const promise = (async () => {
28
+ try {
29
+ const res = await fetch(apiUrl(`/mail/messages/${encodeURIComponent(uid)}/content`), { credentials: "include", signal: controller.signal });
30
+ return res.ok && (res.headers.get("content-type") ?? "").includes("text/html") ? await res.text() : undefined;
31
+ }
32
+ catch {
33
+ return undefined;
34
+ }
35
+ finally {
36
+ clearTimeout(timer);
37
+ }
38
+ })();
39
+ contentCache.set(uid, { at: Date.now(), promise });
40
+ void promise.then((html) => {
41
+ if (html === undefined && contentCache.get(uid)?.promise === promise) {
42
+ contentCache.delete(uid);
43
+ }
44
+ });
45
+ return promise;
46
+ }
47
+ /** Rejects when `promise` hasn't settled within `ms` (`getMessageRawContent()` takes no `AbortSignal`, so it is raced rather than aborted). */
48
+ function withTimeout(promise, ms) {
49
+ let timer;
50
+ const timeout = new Promise((_resolve, reject) => {
51
+ timer = setTimeout(() => reject(new Error("Timed out.")), ms);
52
+ });
53
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
54
+ }
55
+ /**
56
+ * Starts fetching what a Reply/Forward of `message` will quote, without waiting for the click - called when the pointer or
57
+ * keyboard reaches a Reply/Reply All/Forward button - so the request is usually already answered when it is clicked.
58
+ * Nothing to fetch for an encrypted message (`loadOriginalMessage()` never asks the server for one's body).
59
+ */
60
+ export function prefetchOriginalMessage(message) {
61
+ if (!message.encrypted) {
62
+ void fetchContentHtml(message.uid);
63
+ }
64
+ }
65
+ /** Forgets every remembered body (the sign-out and test seam). */
66
+ export function clearOriginalMessageCache() {
67
+ contentCache.clear();
68
+ }
9
69
  /** The recipients one address-list header names, RFC 2047-decoded, with their display names. */
10
70
  function headerRecipients(value, type) {
11
71
  if (!value) {
@@ -50,23 +110,19 @@ export async function loadOriginalMessage(message, security, options = {}) {
50
110
  }
51
111
  let needsRaw = !!options.recipients && result.recipients === undefined;
52
112
  if (!recovered) {
53
- try {
54
- const res = await fetch(apiUrl(`/mail/messages/${encodeURIComponent(message.uid)}/content`), { credentials: "include" });
55
- if (res.ok && (res.headers.get("content-type") ?? "").includes("text/html")) {
56
- result.body = { html: await res.text() };
57
- }
58
- else {
59
- needsRaw = true;
60
- }
113
+ // Never rejects; nothing (a plain-text message, a failure, a timeout) leaves the raw message below as the other way
114
+ // to the body.
115
+ const html = await fetchContentHtml(message.uid);
116
+ if (html !== undefined) {
117
+ result.body = { html };
61
118
  }
62
- catch {
63
- // The raw message below is the other way to the body.
119
+ else {
64
120
  needsRaw = true;
65
121
  }
66
122
  }
67
123
  try {
68
124
  if (needsRaw) {
69
- const entity = parseMimeEntity(await getMessageRawContent(message.uid));
125
+ const entity = parseMimeEntity(await withTimeout(getMessageRawContent(message.uid), QUOTE_FETCH_TIMEOUT_MS));
70
126
  if (!recovered && result.body.html === undefined) {
71
127
  const body = extractDisplayBody(entity);
72
128
  result.body = body.html !== undefined ? { html: body.html } : body.text !== undefined ? { text: body.text } : {};
@@ -1,7 +1,9 @@
1
1
  import React, { PropsWithChildren } from "react";
2
- import { Folder, Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
2
+ import { Folder, Mailbox, Message } from "@rapidmx/react-shared/mail/mailApi.js";
3
3
  import { AppShellProps } from "../../layout/AppShell.js";
4
4
  import { LiveUpdates } from "../../../mail/useMailLiveUpdates.js";
5
+ import { MAILBOX_LIST_LIMIT } from "../../../mail/useMailConnection.js";
6
+ import { CountTracker } from "../../../mail/folderCounts.js";
5
7
  export type MailShellProps = Omit<AppShellProps, "active">;
6
8
  /** One mailbox's own mail folders (already filtered to `MAIL_FOLDER_TYPES` and unsorted) - one entry per
7
9
  * mailbox in `mailboxes`, fetched in parallel so every accessible mailbox's folder tree can render
@@ -48,11 +50,17 @@ export interface MailShellContextValue {
48
50
  * context value, which is only ever read outside a real shell.
49
51
  */
50
52
  live: LiveUpdates;
53
+ /**
54
+ * Applies a change to a message (`previous` to `next`; `next` absent when it was deleted) to the folder badges at once, and
55
+ * hands back what to call when the server has answered: `settle()` keeps it, `revert()` takes it back. Every place that
56
+ * reads, moves or deletes a message goes through this (see `setReadState()`), so the badges follow. A no-op on the default
57
+ * context value, which is only ever read outside a real shell.
58
+ */
59
+ trackMessageChange: (previous: Message, next: Message | null) => CountTracker;
51
60
  }
52
61
  /** Reads the mailbox/folder a page is currently showing, as resolved by the enclosing `MailShell`. */
53
62
  export declare function useMailShell(): MailShellContextValue;
54
- /** Page size of the one `listMailboxes()` call this shell makes. */
55
- export declare const MAILBOX_LIST_LIMIT = 100;
63
+ export { MAILBOX_LIST_LIMIT };
56
64
  /**
57
65
  * Mail's own contextual sidebar (every accessible mailbox's own folder tree, plus a merged "All Mailboxes"
58
66
  * aggregate section) + content area, rendered inside the shared `AppShell` chrome (icon rail, header,
@@ -69,4 +77,4 @@ export declare const MAILBOX_LIST_LIMIT = 100;
69
77
  * aggregate section) simultaneously, matching the shared-mailbox feature's own "display as a separate set
70
78
  * of folders" requirement.
71
79
  */
72
- export default function MailShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, pluginNav, children, }: PropsWithChildren<MailShellProps>): React.JSX.Element;
80
+ export default function MailShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, children, }: PropsWithChildren<MailShellProps>): React.JSX.Element;