@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,141 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useEffect, useRef, useState } from "react";
6
+ import { HiOutlineXMark } from "react-icons/hi2";
7
+ import type { NewMailNotice } from "../../mail/newMailNotifications.js";
8
+ import { TOAST_DURATION_MS } from "../../mail/useNewMailNotifications.js";
9
+
10
+ export interface NewMailToastsProps {
11
+ /** The pop-ups to show, oldest first. */
12
+ toasts: NewMailNotice[];
13
+ onDismiss: (uid: string) => void;
14
+ /** Show the "Turn on desktop notifications" offer in the first pop-up. */
15
+ offerDesktop: boolean;
16
+ onEnableDesktop: () => void;
17
+ onDeclineDesktop: () => void;
18
+ }
19
+
20
+ /** Whether the tab is out of view - a pop-up's clock stops meanwhile, so it is still there when the reader comes back. */
21
+ function useTabHidden(): boolean {
22
+ const [hidden, setHidden] = useState(false);
23
+ useEffect(() => {
24
+ const update = () => setHidden(document.visibilityState === "hidden");
25
+ update();
26
+ document.addEventListener("visibilitychange", update);
27
+ return () => document.removeEventListener("visibilitychange", update);
28
+ }, []);
29
+ return hidden;
30
+ }
31
+
32
+ interface ToastItemProps {
33
+ toast: NewMailNotice;
34
+ tabHidden: boolean;
35
+ onDismiss: (uid: string) => void;
36
+ offer?: { onEnable: () => void; onDecline: () => void };
37
+ }
38
+
39
+ /** One pop-up. Its 8 seconds only run while it is neither hovered nor holding keyboard focus, and the tab is in view. */
40
+ function ToastItem({ toast, tabHidden, onDismiss, offer }: ToastItemProps) {
41
+ const [hovered, setHovered] = useState(false);
42
+ const [focused, setFocused] = useState(false);
43
+ const remainingRef = useRef(TOAST_DURATION_MS);
44
+ const paused = tabHidden || hovered || focused;
45
+
46
+ useEffect(() => {
47
+ if (paused) {
48
+ return;
49
+ }
50
+ const startedAt = Date.now();
51
+ const timer = setTimeout(() => onDismiss(toast.uid), remainingRef.current);
52
+ return () => {
53
+ clearTimeout(timer);
54
+ remainingRef.current = Math.max(0, remainingRef.current - (Date.now() - startedAt));
55
+ };
56
+ }, [paused, toast.uid, onDismiss]);
57
+
58
+ const sender = toast.senderName || toast.senderAddress;
59
+ return (
60
+ <div
61
+ data-testid="new-mail-toast"
62
+ onMouseEnter={() => setHovered(true)}
63
+ onMouseLeave={() => setHovered(false)}
64
+ onFocus={() => setFocused(true)}
65
+ onBlur={(event) => {
66
+ // Focus moving between this pop-up's own controls is not leaving it.
67
+ if (!event.currentTarget.contains(event.relatedTarget)) {
68
+ setFocused(false);
69
+ }
70
+ }}
71
+ className="rr-toast-in pointer-events-auto relative rounded-md border border-border border-l-4 border-l-primary bg-surface text-text shadow-modal"
72
+ >
73
+ <a
74
+ href={toast.href}
75
+ onClick={() => onDismiss(toast.uid)}
76
+ className="block rounded-md p-3 pr-10 hover:bg-surface-alt focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
77
+ >
78
+ <span className="block text-xs font-semibold uppercase tracking-wide text-primary-dark">New message</span>
79
+ <span className="mt-0.5 flex min-w-0 items-baseline gap-1.5 text-sm">
80
+ <span className="truncate font-bold">{sender}</span>
81
+ {toast.senderName && <span className="truncate text-xs text-text-muted">&lt;{toast.senderAddress}&gt;</span>}
82
+ </span>
83
+ <span className="block truncate text-sm font-semibold">{toast.subject}</span>
84
+ {toast.preview && <span className="mt-0.5 block text-xs text-text-muted [overflow-wrap:anywhere]">{toast.preview}</span>}
85
+ </a>
86
+ <button
87
+ type="button"
88
+ onClick={() => onDismiss(toast.uid)}
89
+ aria-label={`Dismiss notification: ${toast.subject}`}
90
+ className="absolute right-1.5 top-1.5 flex h-7 w-7 items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text focus-visible:outline-2 focus-visible:outline-primary"
91
+ >
92
+ <HiOutlineXMark size={16} aria-hidden="true" />
93
+ </button>
94
+ {offer && (
95
+ <div className="flex flex-wrap items-center gap-2 border-t border-border px-3 py-2 text-xs text-text-muted">
96
+ <span className="basis-full">Get a desktop notification when mail arrives while this tab is in the background.</span>
97
+ <button
98
+ type="button"
99
+ onClick={offer.onEnable}
100
+ className="rounded-sm bg-primary px-2.5 py-1 text-xs font-semibold text-text-on-primary hover:bg-primary-dark"
101
+ >
102
+ Turn on desktop notifications
103
+ </button>
104
+ <button type="button" onClick={offer.onDecline} className="rounded-sm px-2 py-1 text-xs font-medium hover:bg-surface-alt hover:text-text">
105
+ Not now
106
+ </button>
107
+ </div>
108
+ )}
109
+ </div>
110
+ );
111
+ }
112
+
113
+ /**
114
+ * The new-mail pop-ups, stacked at the top right (`fixed`, above everything). The container is always rendered - a live region
115
+ * has to exist before its content changes to be announced - and is a polite `status` region, so an arrival is read out without
116
+ * interrupting. Each pop-up shows the sender's name and address, the subject and a short preview, as text; clicking it opens the
117
+ * message; it has a dismiss button and goes by itself after a few seconds (see `ToastItem`). Motion is CSS-only and switched off
118
+ * by `prefers-reduced-motion` (`.rr-toast-in` in `app.css`).
119
+ */
120
+ export default function NewMailToasts({ toasts, onDismiss, offerDesktop, onEnableDesktop, onDeclineDesktop }: NewMailToastsProps) {
121
+ const tabHidden = useTabHidden();
122
+ return (
123
+ <div
124
+ role="status"
125
+ aria-live="polite"
126
+ aria-label="New mail"
127
+ data-testid="new-mail-toasts"
128
+ className="pointer-events-none fixed right-4 top-4 z-50 flex w-[22rem] max-w-[calc(100vw-2rem)] flex-col gap-2"
129
+ >
130
+ {toasts.map((toast, index) => (
131
+ <ToastItem
132
+ key={toast.uid}
133
+ toast={toast}
134
+ tabHidden={tabHidden}
135
+ onDismiss={onDismiss}
136
+ offer={offerDesktop && index === 0 ? { onEnable: onEnableDesktop, onDecline: onDeclineDesktop } : undefined}
137
+ />
138
+ ))}
139
+ </div>
140
+ );
141
+ }
@@ -1,160 +1,279 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { PropsWithChildren, createContext, useContext, useMemo, useState } from "react";
6
- import { DraftThreading } from "@rapidmx/react-shared/mail/mailApi.js";
7
- import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
8
- import ComposeWindow from "./ComposeWindow.js";
9
-
10
- export interface ComposeSession {
11
- id: string;
12
- /** The initial sending mailbox - absent means "the caller's own mailbox" (see `ComposeWindow`). */
13
- mailboxUid?: string;
14
- initialTo?: string;
15
- initialCc?: string;
16
- initialSubject?: string;
17
- /** Pre-built HTML (already includes its own quote-attribution wrapper — see `composeQuoting.ts`)
18
- * inserted below the resolved default signature. Absent for a fresh, non-reply/forward compose. Its presence
19
- * is what makes the window open with the caret at the top of the body instead of in To. */
20
- initialQuotedHtml?: string;
21
- /** See `OpenComposeInput.encrypt`'s own doc comment. */
22
- initialEncrypt?: boolean;
23
- /** See `OpenComposeInput.threading`'s own doc comment - handed to `createDraft()` by the window. */
24
- threading?: DraftThreading;
25
- /** Which of a signature's two "default" flags to resolve against — `"new"` (the default) uses
26
- * `isDefaultForNewMessages`, `"reply_forward"` uses `isDefaultForReplyForward`. */
27
- signatureContext: "new" | "reply_forward";
28
- /** See `OpenComposeInput.suppressSigning`'s own doc comment. */
29
- suppressSigning?: boolean;
30
- minimized: boolean;
31
- }
32
-
33
- export interface OpenComposeInput {
34
- /** The sending mailbox to start with. Omit for a fresh message (defaults to the caller's own mailbox);
35
- * a reply/forward passes the original message's mailbox so a shared mailbox's mail replies from it.
36
- * Either way the user can change it via the compose window's From field. */
37
- mailboxUid?: string;
38
- /** Prefills the To field — e.g. Contacts' "Email" toolbar action, or Reply/Reply All/Forward. */
39
- to?: string;
40
- /** Prefills the Cc field and reveals the Cc/Bcc row — Reply All only. */
41
- cc?: string;
42
- /** Prefills the Subject field — Reply/Reply All/Forward. */
43
- subject?: string;
44
- /** See `ComposeSession.initialQuotedHtml`'s own doc comment. */
45
- quotedHtml?: string;
46
- /** See `ComposeSession.signatureContext`'s own doc comment. Defaults to `"new"` — every existing
47
- * caller (Contacts' "Email" action, the folder-sidebar "Compose" button) is a fresh compose. */
48
- signatureContext?: "new" | "reply_forward";
49
- /** `true` when the caller (`MessageDetailPane.tsx`'s Reply/Reply All) has already determined, via
50
- * `composeSecurity.ts`'s `isLikelyMailingList()`, that the message being replied to came from a
51
- * mailing list — a list that appends a footer after signing invalidates the signature (spec's own
52
- * "Mailing lists" note), so the new compose window defaults its Sign toggle off rather than on.
53
- * The user can still turn it back on manually; this only changes the *default*. */
54
- suppressSigning?: boolean;
55
- /** `true` for a reply to or forward of an encrypted message, whose quote may carry its decrypted content: the
56
- * compose window starts with "Encrypt this message" requested, so it is never autosaved as a plaintext draft and
57
- * can't be sent unencrypted without the user explicitly choosing to. */
58
- encrypt?: boolean;
59
- /** The thread this compose continues (`buildReplyThreading()` over the message being replied to or
60
- * forwarded), recorded on the draft by `createDraft()`. Without it the message is relayed with no
61
- * `In-Reply-To`/`References` at all and every mail system - the sender's own Sent Items included - files it
62
- * as a new conversation rather than part of the thread. Absent for a fresh compose, which starts one. */
63
- threading?: DraftThreading;
64
- }
65
-
66
- export interface ComposeContextValue {
67
- /** Opens a new compose window, stacked alongside any already open (Gmail allows several at once). */
68
- openCompose: (input: OpenComposeInput) => void;
69
- }
70
-
71
- const ComposeContext = createContext<ComposeContextValue>({ openCompose: () => undefined });
72
-
73
- /** Opens the floating Compose window from anywhere inside `AppShell` (any of the four webmail apps). */
74
- export function useCompose(): ComposeContextValue {
75
- return useContext(ComposeContext);
76
- }
77
-
78
- /**
79
- * Owns every currently-open Compose window and renders them stacked bottom-right, Gmail-style — see
80
- * `ComposeWindow`'s own doc comment for why this replaced the old dedicated `/compose` page. Mounted
81
- * once in `AppShell`, so every webmail app (Mail/Calendar/Contacts/Tasks) shares the same instance:
82
- * opening Compose from Contacts' "Email" action, for instance, overlays the window on top of whatever
83
- * app is currently showing, exactly like opening it from Mail's own sidebar button.
84
- */
85
- export default function ComposeProvider({ children, userUid, trusted }: PropsWithChildren<{ userUid?: string; trusted?: boolean }>) {
86
- const [sessions, setSessions] = useState<ComposeSession[]>([]);
87
- const isMobile = useIsMobile();
88
-
89
- function openCompose({
90
- mailboxUid,
91
- to,
92
- cc,
93
- subject,
94
- quotedHtml,
95
- signatureContext = "new",
96
- suppressSigning,
97
- encrypt,
98
- threading,
99
- }: OpenComposeInput) {
100
- setSessions((prev) => [
101
- ...prev,
102
- {
103
- id: crypto.randomUUID(),
104
- mailboxUid,
105
- initialTo: to,
106
- initialCc: cc,
107
- initialSubject: subject,
108
- initialQuotedHtml: quotedHtml,
109
- initialEncrypt: encrypt,
110
- threading,
111
- signatureContext,
112
- suppressSigning,
113
- minimized: false,
114
- },
115
- ]);
116
- }
117
-
118
- function closeCompose(id: string) {
119
- setSessions((prev) => prev.filter((s) => s.id !== id));
120
- }
121
-
122
- function toggleMinimize(id: string) {
123
- setSessions((prev) => prev.map((s) => (s.id === id ? { ...s, minimized: !s.minimized } : s)));
124
- }
125
-
126
- const value = useMemo<ComposeContextValue>(() => ({ openCompose }), []);
127
-
128
- // On mobile, a non-minimized `ComposeWindow` renders full-screen (see that component's own doc
129
- // comment) — Gmail-style stacking of several full-screen overlays at once makes no sense there, so
130
- // at most one non-minimized session is ever shown: the most recently opened one. Minimized
131
- // sessions are small chips regardless of device, so every one of those still shows — an earlier
132
- // session becomes visible again (as its own chip, or full-screen if it's the new most-recent
133
- // non-minimized one) once whatever's currently "on top" is closed or minimized. The others stay
134
- // mounted, just hidden: unmounting them would throw away everything typed into them.
135
- const lastNonMinimizedId = isMobile ? [...sessions].reverse().find((s) => !s.minimized)?.id : undefined;
136
-
137
- return (
138
- <ComposeContext.Provider value={value}>
139
- {children}
140
- {sessions.length > 0 && (
141
- <div className="fixed bottom-0 right-6 flex items-end gap-3 z-50">
142
- {sessions.map((session) => {
143
- const hidden = isMobile && !session.minimized && session.id !== lastNonMinimizedId;
144
- return (
145
- <div key={session.id} hidden={hidden} className={hidden ? "hidden" : "contents"}>
146
- <ComposeWindow
147
- session={session}
148
- userUid={userUid}
149
- trusted={trusted}
150
- onClose={() => closeCompose(session.id)}
151
- onToggleMinimize={() => toggleMinimize(session.id)}
152
- />
153
- </div>
154
- );
155
- })}
156
- </div>
157
- )}
158
- </ComposeContext.Provider>
159
- );
160
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { PropsWithChildren, createContext, useContext, useEffect, useMemo, useState } from "react";
6
+ import { DraftThreading } from "@rapidmx/react-shared/mail/mailApi.js";
7
+ import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
8
+ import { markComposePhase } from "./composePerf.js";
9
+ import ComposeWindowPlaceholder from "./ComposeWindowPlaceholder.js";
10
+
11
+ type ComposeWindowComponent = typeof import("./ComposeWindow.js").default;
12
+
13
+ let loadedComposeWindow: ComposeWindowComponent | undefined;
14
+ let loadingComposeWindow: Promise<ComposeWindowComponent> | undefined;
15
+
16
+ /**
17
+ * The compose window - with the rich-text editor (TipTap/ProseMirror), the recipient inputs and the send pipeline - is
18
+ * the largest piece of the mail app and most page loads never open it, so it is a separate chunk, loaded here once. Not
19
+ * `React.lazy()`: a chunk that failed to download (offline, a deploy that replaced it) must be retryable, and `lazy()` keeps
20
+ * the rejection for good.
21
+ */
22
+ function loadComposeWindow(): Promise<ComposeWindowComponent> {
23
+ loadingComposeWindow ??= import("./ComposeWindow.js").then(
24
+ (module) => (loadedComposeWindow = module.default),
25
+ (err) => {
26
+ loadingComposeWindow = undefined;
27
+ throw err;
28
+ },
29
+ );
30
+ return loadingComposeWindow;
31
+ }
32
+
33
+ /**
34
+ * Starts downloading the compose window's code without opening one - on hover/focus of a Compose or Reply button and when
35
+ * the browser is idle after the inbox has loaded - so that clicking it finds the chunk already in memory. Safe to call
36
+ * any number of times, and never rejects: a failed download is retried by the click itself.
37
+ */
38
+ export function prefetchComposeWindow(): void {
39
+ loadComposeWindow().catch(() => undefined);
40
+ }
41
+
42
+ /** Test seam: forgets the loaded compose window chunk, as a fresh page load would. */
43
+ export function resetComposeWindowLoader(): void {
44
+ loadedComposeWindow = undefined;
45
+ loadingComposeWindow = undefined;
46
+ }
47
+
48
+ /** The compose window component once its chunk is in, else `undefined` (while loading, or `failed`), and a way to retry. */
49
+ function useComposeWindowComponent(wanted: boolean): { Component: ComposeWindowComponent | undefined; failed: boolean; retry: () => void } {
50
+ const [loaded, setComponent] = useState<ComposeWindowComponent | undefined>();
51
+ // Also read straight from the module: a prefetch that finished after this provider mounted has no state update to
52
+ // announce it, and the first click must not flash the placeholder for a chunk that is already here.
53
+ const Component = loaded ?? loadedComposeWindow;
54
+ const [failed, setFailed] = useState(false);
55
+ const [attempt, setAttempt] = useState(0);
56
+ useEffect(() => {
57
+ if (!wanted || Component) {
58
+ return;
59
+ }
60
+ let cancelled = false;
61
+ setFailed(false);
62
+ loadComposeWindow().then(
63
+ (component) => {
64
+ if (!cancelled) {
65
+ setComponent(() => component);
66
+ }
67
+ },
68
+ () => {
69
+ if (!cancelled) {
70
+ setFailed(true);
71
+ }
72
+ },
73
+ );
74
+ return () => {
75
+ cancelled = true;
76
+ };
77
+ }, [wanted, Component, attempt]);
78
+ return { Component, failed, retry: () => setAttempt((n) => n + 1) };
79
+ }
80
+
81
+ export interface ComposeSession {
82
+ id: string;
83
+ /** The initial sending mailbox - absent means "the caller's own mailbox" (see `ComposeWindow`). */
84
+ mailboxUid?: string;
85
+ initialTo?: string;
86
+ initialCc?: string;
87
+ initialSubject?: string;
88
+ /** Pre-built HTML (already includes its own quote-attribution wrapper — see `composeQuoting.ts`)
89
+ * inserted below the resolved default signature. Absent for a fresh, non-reply/forward compose. Its presence
90
+ * is what makes the window open with the caret at the top of the body instead of in To. */
91
+ initialQuotedHtml?: string;
92
+ /** See `OpenComposeInput.encrypt`'s own doc comment. */
93
+ initialEncrypt?: boolean;
94
+ /** See `OpenComposeInput.threading`'s own doc comment - handed to `createDraft()` by the window. */
95
+ threading?: DraftThreading;
96
+ /** Which of a signature's two "default" flags to resolve against — `"new"` (the default) uses
97
+ * `isDefaultForNewMessages`, `"reply_forward"` uses `isDefaultForReplyForward`. */
98
+ signatureContext: "new" | "reply_forward";
99
+ /** See `OpenComposeInput.suppressSigning`'s own doc comment. */
100
+ suppressSigning?: boolean;
101
+ /** The window opened before its quoted original was known (see `OpenComposeInput.pending`): the body waits for it. */
102
+ quotePending?: boolean;
103
+ /** What `OpenComposeInput.pending` resolved with, once it has - see `ComposeLateInput`. */
104
+ late?: ComposeLateInput;
105
+ minimized: boolean;
106
+ }
107
+
108
+ /** What a reply or forward could only work out after its window was already open (see `OpenComposeInput.pending`). */
109
+ export interface ComposeLateInput {
110
+ /** The quoted original - see `OpenComposeInput.quotedHtml`. */
111
+ quotedHtml?: string;
112
+ /** A better To than the one the window opened with - applied only if the To field is still exactly what it opened with. */
113
+ to?: string;
114
+ /** A better Cc, under the same rule as `to`. */
115
+ cc?: string;
116
+ }
117
+
118
+ export interface OpenComposeInput {
119
+ /** The sending mailbox to start with. Omit for a fresh message (defaults to the caller's own mailbox);
120
+ * a reply/forward passes the original message's mailbox so a shared mailbox's mail replies from it.
121
+ * Either way the user can change it via the compose window's From field. */
122
+ mailboxUid?: string;
123
+ /** Prefills the To field — e.g. Contacts' "Email" toolbar action, or Reply/Reply All/Forward. */
124
+ to?: string;
125
+ /** Prefills the Cc field and reveals the Cc/Bcc row — Reply All only. */
126
+ cc?: string;
127
+ /** Prefills the Subject field — Reply/Reply All/Forward. */
128
+ subject?: string;
129
+ /** See `ComposeSession.initialQuotedHtml`'s own doc comment. */
130
+ quotedHtml?: string;
131
+ /** See `ComposeSession.signatureContext`'s own doc comment. Defaults to `"new"` — every existing
132
+ * caller (Contacts' "Email" action, the folder-sidebar "Compose" button) is a fresh compose. */
133
+ signatureContext?: "new" | "reply_forward";
134
+ /** `true` when the caller (`MessageDetailPane.tsx`'s Reply/Reply All) has already determined, via
135
+ * `composeSecurity.ts`'s `isLikelyMailingList()`, that the message being replied to came from a
136
+ * mailing list — a list that appends a footer after signing invalidates the signature (spec's own
137
+ * "Mailing lists" note), so the new compose window defaults its Sign toggle off rather than on.
138
+ * The user can still turn it back on manually; this only changes the *default*. */
139
+ suppressSigning?: boolean;
140
+ /** `true` for a reply to or forward of an encrypted message, whose quote may carry its decrypted content: the
141
+ * compose window starts with "Encrypt this message" requested, so it is never autosaved as a plaintext draft and
142
+ * can't be sent unencrypted without the user explicitly choosing to. */
143
+ encrypt?: boolean;
144
+ /** The thread this compose continues (`buildReplyThreading()` over the message being replied to or
145
+ * forwarded), recorded on the draft by `createDraft()`. Without it the message is relayed with no
146
+ * `In-Reply-To`/`References` at all and every mail system - the sender's own Sent Items included - files it
147
+ * as a new conversation rather than part of the thread. Absent for a fresh compose, which starts one. */
148
+ threading?: DraftThreading;
149
+ /**
150
+ * Something the window shouldn't wait for before appearing: a reply or forward opens at once from what is already in
151
+ * memory (the message's own subject and sender), and this settles with what needed the network - the original's body
152
+ * to quote, and for Reply All the recipients its headers name. The compose window shows meanwhile, its body area
153
+ * waiting, and fills the body in when this resolves; `to`/`cc` replace the opened-with values only if the user hasn't
154
+ * touched those fields. Must never reject (resolve `undefined` for "nothing more"); when set, `quotedHtml` is ignored
155
+ * until it resolves.
156
+ */
157
+ pending?: Promise<ComposeLateInput | undefined>;
158
+ }
159
+
160
+ export interface ComposeContextValue {
161
+ /** Opens a new compose window, stacked alongside any already open (Gmail allows several at once). */
162
+ openCompose: (input: OpenComposeInput) => void;
163
+ }
164
+
165
+ const ComposeContext = createContext<ComposeContextValue>({ openCompose: () => undefined });
166
+
167
+ /** Opens the floating Compose window from anywhere inside `AppShell` (any of the four webmail apps). */
168
+ export function useCompose(): ComposeContextValue {
169
+ return useContext(ComposeContext);
170
+ }
171
+
172
+ /**
173
+ * Owns every currently-open Compose window and renders them stacked bottom-right, Gmail-style — see
174
+ * `ComposeWindow`'s own doc comment for why this replaced the old dedicated `/compose` page. Mounted
175
+ * once in `AppShell`, so every webmail app (Mail/Calendar/Contacts/Tasks) shares the same instance:
176
+ * opening Compose from Contacts' "Email" action, for instance, overlays the window on top of whatever
177
+ * app is currently showing, exactly like opening it from Mail's own sidebar button.
178
+ */
179
+ export default function ComposeProvider({ children, userUid, trusted }: PropsWithChildren<{ userUid?: string; trusted?: boolean }>) {
180
+ const [sessions, setSessions] = useState<ComposeSession[]>([]);
181
+ const isMobile = useIsMobile();
182
+ const { Component: ComposeWindow, failed, retry: retryLoad } = useComposeWindowComponent(sessions.length > 0);
183
+
184
+ function openCompose({
185
+ mailboxUid,
186
+ to,
187
+ cc,
188
+ subject,
189
+ quotedHtml,
190
+ signatureContext = "new",
191
+ suppressSigning,
192
+ encrypt,
193
+ threading,
194
+ pending,
195
+ }: OpenComposeInput) {
196
+ const id = crypto.randomUUID();
197
+ markComposePhase(id, "click");
198
+ setSessions((prev) => [
199
+ ...prev,
200
+ {
201
+ id,
202
+ mailboxUid,
203
+ initialTo: to,
204
+ initialCc: cc,
205
+ initialSubject: subject,
206
+ initialQuotedHtml: quotedHtml,
207
+ initialEncrypt: encrypt,
208
+ threading,
209
+ signatureContext,
210
+ suppressSigning,
211
+ quotePending: !!pending,
212
+ minimized: false,
213
+ },
214
+ ]);
215
+ // A window closed before this settles is simply not in the list any more - nothing to update.
216
+ void pending
217
+ ?.catch(() => undefined)
218
+ .then((late) =>
219
+ setSessions((prev) =>
220
+ prev.map((s) =>
221
+ s.id === id ? { ...s, quotePending: false, late, initialQuotedHtml: late?.quotedHtml ?? s.initialQuotedHtml } : s,
222
+ ),
223
+ ),
224
+ );
225
+ }
226
+
227
+ function closeCompose(id: string) {
228
+ setSessions((prev) => prev.filter((s) => s.id !== id));
229
+ }
230
+
231
+ function toggleMinimize(id: string) {
232
+ setSessions((prev) => prev.map((s) => (s.id === id ? { ...s, minimized: !s.minimized } : s)));
233
+ }
234
+
235
+ const value = useMemo<ComposeContextValue>(() => ({ openCompose }), []);
236
+
237
+ // On mobile, a non-minimized `ComposeWindow` renders full-screen (see that component's own doc
238
+ // comment) — Gmail-style stacking of several full-screen overlays at once makes no sense there, so
239
+ // at most one non-minimized session is ever shown: the most recently opened one. Minimized
240
+ // sessions are small chips regardless of device, so every one of those still shows — an earlier
241
+ // session becomes visible again (as its own chip, or full-screen if it's the new most-recent
242
+ // non-minimized one) once whatever's currently "on top" is closed or minimized. The others stay
243
+ // mounted, just hidden: unmounting them would throw away everything typed into them.
244
+ const lastNonMinimizedId = isMobile ? [...sessions].reverse().find((s) => !s.minimized)?.id : undefined;
245
+
246
+ return (
247
+ <ComposeContext.Provider value={value}>
248
+ {children}
249
+ {sessions.length > 0 && (
250
+ <div className="fixed bottom-0 right-6 flex items-end gap-3 z-50">
251
+ {sessions.map((session) => {
252
+ const hidden = isMobile && !session.minimized && session.id !== lastNonMinimizedId;
253
+ return (
254
+ <div key={session.id} hidden={hidden} className={hidden ? "hidden" : "contents"}>
255
+ {ComposeWindow ? (
256
+ <ComposeWindow
257
+ session={session}
258
+ userUid={userUid}
259
+ trusted={trusted}
260
+ onClose={() => closeCompose(session.id)}
261
+ onToggleMinimize={() => toggleMinimize(session.id)}
262
+ />
263
+ ) : (
264
+ <ComposeWindowPlaceholder
265
+ session={session}
266
+ failed={failed}
267
+ onRetry={retryLoad}
268
+ onClose={() => closeCompose(session.id)}
269
+ onToggleMinimize={() => toggleMinimize(session.id)}
270
+ />
271
+ )}
272
+ </div>
273
+ );
274
+ })}
275
+ </div>
276
+ )}
277
+ </ComposeContext.Provider>
278
+ );
279
+ }