@rapidmx/web-client 0.2.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 (168) hide show
  1. package/LICENSE +382 -0
  2. package/apps/admin/_404.tsx +9 -0
  3. package/apps/admin/_500.tsx +9 -0
  4. package/apps/admin/_layout.tsx +41 -0
  5. package/apps/admin/audit-log/index.tsx +187 -0
  6. package/apps/admin/branding/index.tsx +404 -0
  7. package/apps/admin/distribution-lists/[uid].tsx +70 -0
  8. package/apps/admin/distribution-lists/index.tsx +115 -0
  9. package/apps/admin/distribution-lists/new/index.tsx +114 -0
  10. package/apps/admin/domains/[uid].tsx +259 -0
  11. package/apps/admin/domains/index.tsx +125 -0
  12. package/apps/admin/domains/new/index.tsx +84 -0
  13. package/apps/admin/index.tsx +82 -0
  14. package/apps/admin/ingest-queue/index.tsx +116 -0
  15. package/apps/admin/mailboxes/[uid].tsx +126 -0
  16. package/apps/admin/mailboxes/new/index.tsx +291 -0
  17. package/apps/admin/quarantine/index.tsx +171 -0
  18. package/apps/admin/transport-rules/[uid].tsx +143 -0
  19. package/apps/admin/transport-rules/_transportRuleConfig.tsx +75 -0
  20. package/apps/admin/transport-rules/index.tsx +117 -0
  21. package/apps/admin/transport-rules/new/index.tsx +105 -0
  22. package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +117 -0
  23. package/apps/shared/components/admin/layout/AdminShell.tsx +186 -0
  24. package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +73 -0
  25. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +154 -0
  26. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +124 -0
  27. package/apps/shared/components/booking/AvailabilityEditor.tsx +118 -0
  28. package/apps/shared/components/calendar/CalendarListSidebar.tsx +119 -0
  29. package/apps/shared/components/calendar/EventModal.tsx +513 -0
  30. package/apps/shared/components/calendar/MonthView.tsx +139 -0
  31. package/apps/shared/components/calendar/RecurrenceEditor.tsx +180 -0
  32. package/apps/shared/components/calendar/ResourcePicker.tsx +80 -0
  33. package/apps/shared/components/calendar/SplitDayView.tsx +137 -0
  34. package/apps/shared/components/calendar/TimeGridView.tsx +198 -0
  35. package/apps/shared/components/calendar/layout/CalendarShell.tsx +211 -0
  36. package/apps/shared/components/contacts/ContactDetailPane.tsx +99 -0
  37. package/apps/shared/components/contacts/ContactForm.tsx +314 -0
  38. package/apps/shared/components/contacts/ContactsSidebar.tsx +242 -0
  39. package/apps/shared/components/contacts/ContactsToolbar.tsx +115 -0
  40. package/apps/shared/components/contacts/layout/ContactsShell.tsx +197 -0
  41. package/apps/shared/components/layout/AppShell.tsx +165 -0
  42. package/apps/shared/components/layout/BrandingChrome.tsx +25 -0
  43. package/apps/shared/components/layout/MailboxProvisioning.tsx +110 -0
  44. package/apps/shared/components/layout/UserMenu.tsx +150 -0
  45. package/apps/shared/components/mail/ConversationList.tsx +61 -0
  46. package/apps/shared/components/mail/ConversationThreadPane.tsx +174 -0
  47. package/apps/shared/components/mail/MessageDetailPane.tsx +390 -0
  48. package/apps/shared/components/mail/compose/ComposeContext.tsx +114 -0
  49. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +418 -0
  50. package/apps/shared/components/mail/compose/ComposeWindow.tsx +518 -0
  51. package/apps/shared/components/mail/compose/EmojiPicker.tsx +64 -0
  52. package/apps/shared/components/mail/compose/GifPicker.tsx +82 -0
  53. package/apps/shared/components/mail/compose/RichTextEditor.tsx +83 -0
  54. package/apps/shared/components/mail/compose/ScheduleSendPicker.tsx +64 -0
  55. package/apps/shared/components/mail/layout/MailShell.tsx +287 -0
  56. package/apps/shared/components/rules/RuleBuilder.tsx +276 -0
  57. package/apps/shared/components/settings/layout/SettingsShell.tsx +223 -0
  58. package/apps/shared/components/tasks/TasksSidebar.tsx +201 -0
  59. package/apps/shared/components/tasks/TasksToolbar.tsx +68 -0
  60. package/apps/shared/components/tasks/layout/TasksShell.tsx +199 -0
  61. package/apps/shared/styles/app.css +265 -0
  62. package/apps/www/_404.tsx +9 -0
  63. package/apps/www/_500.tsx +9 -0
  64. package/apps/www/_layout.tsx +45 -0
  65. package/apps/www/calendar/index.tsx +407 -0
  66. package/apps/www/contacts/[uid].tsx +88 -0
  67. package/apps/www/contacts/index.tsx +436 -0
  68. package/apps/www/index.tsx +375 -0
  69. package/apps/www/messages/[uid].tsx +70 -0
  70. package/apps/www/settings/auto-reply/index.tsx +129 -0
  71. package/apps/www/settings/booking-types/[uid].tsx +300 -0
  72. package/apps/www/settings/booking-types/index.tsx +98 -0
  73. package/apps/www/settings/booking-types/new/index.tsx +212 -0
  74. package/apps/www/settings/filters/[uid].tsx +155 -0
  75. package/apps/www/settings/filters/_mailFilterRuleConfig.tsx +102 -0
  76. package/apps/www/settings/filters/index.tsx +99 -0
  77. package/apps/www/settings/filters/new/index.tsx +126 -0
  78. package/apps/www/settings/focused-inbox/index.tsx +148 -0
  79. package/apps/www/settings/read-receipts/index.tsx +125 -0
  80. package/apps/www/settings/signatures/[uid].tsx +167 -0
  81. package/apps/www/settings/signatures/index.tsx +85 -0
  82. package/apps/www/settings/signatures/new/index.tsx +129 -0
  83. package/apps/www/settings/signatures/signatureDefaults.ts +36 -0
  84. package/apps/www/tasks/index.tsx +530 -0
  85. package/dist/apps/admin/_404.js +4 -0
  86. package/dist/apps/admin/_500.js +4 -0
  87. package/dist/apps/admin/_layout.js +10 -0
  88. package/dist/apps/admin/audit-log/index.js +66 -0
  89. package/dist/apps/admin/branding/index.js +126 -0
  90. package/dist/apps/admin/distribution-lists/[uid].js +34 -0
  91. package/dist/apps/admin/distribution-lists/index.js +31 -0
  92. package/dist/apps/admin/distribution-lists/new/index.js +51 -0
  93. package/dist/apps/admin/domains/[uid].js +102 -0
  94. package/dist/apps/admin/domains/index.js +31 -0
  95. package/dist/apps/admin/domains/new/index.js +41 -0
  96. package/dist/apps/admin/index.js +32 -0
  97. package/dist/apps/admin/ingest-queue/index.js +48 -0
  98. package/dist/apps/admin/mailboxes/[uid].js +59 -0
  99. package/dist/apps/admin/mailboxes/new/index.js +93 -0
  100. package/dist/apps/admin/quarantine/index.js +66 -0
  101. package/dist/apps/admin/transport-rules/[uid].js +87 -0
  102. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +37 -0
  103. package/dist/apps/admin/transport-rules/index.js +32 -0
  104. package/dist/apps/admin/transport-rules/new/index.js +50 -0
  105. package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +64 -0
  106. package/dist/apps/shared/components/admin/layout/AdminShell.js +94 -0
  107. package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +16 -0
  108. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +59 -0
  109. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +67 -0
  110. package/dist/apps/shared/components/booking/AvailabilityEditor.js +45 -0
  111. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +44 -0
  112. package/dist/apps/shared/components/calendar/EventModal.js +167 -0
  113. package/dist/apps/shared/components/calendar/MonthView.js +38 -0
  114. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +55 -0
  115. package/dist/apps/shared/components/calendar/ResourcePicker.js +36 -0
  116. package/dist/apps/shared/components/calendar/SplitDayView.js +44 -0
  117. package/dist/apps/shared/components/calendar/TimeGridView.js +58 -0
  118. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +96 -0
  119. package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -0
  120. package/dist/apps/shared/components/contacts/ContactForm.js +85 -0
  121. package/dist/apps/shared/components/contacts/ContactsSidebar.js +105 -0
  122. package/dist/apps/shared/components/contacts/ContactsToolbar.js +32 -0
  123. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +93 -0
  124. package/dist/apps/shared/components/layout/AppShell.js +68 -0
  125. package/dist/apps/shared/components/layout/BrandingChrome.js +18 -0
  126. package/dist/apps/shared/components/layout/MailboxProvisioning.js +75 -0
  127. package/dist/apps/shared/components/layout/UserMenu.js +59 -0
  128. package/dist/apps/shared/components/mail/ConversationList.js +17 -0
  129. package/dist/apps/shared/components/mail/ConversationThreadPane.js +112 -0
  130. package/dist/apps/shared/components/mail/MessageDetailPane.js +153 -0
  131. package/dist/apps/shared/components/mail/compose/ComposeContext.js +55 -0
  132. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +88 -0
  133. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +261 -0
  134. package/dist/apps/shared/components/mail/compose/EmojiPicker.js +25 -0
  135. package/dist/apps/shared/components/mail/compose/GifPicker.js +36 -0
  136. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +60 -0
  137. package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.js +33 -0
  138. package/dist/apps/shared/components/mail/layout/MailShell.js +139 -0
  139. package/dist/apps/shared/components/rules/RuleBuilder.js +78 -0
  140. package/dist/apps/shared/components/settings/layout/SettingsShell.js +100 -0
  141. package/dist/apps/shared/components/tasks/TasksSidebar.js +81 -0
  142. package/dist/apps/shared/components/tasks/TasksToolbar.js +15 -0
  143. package/dist/apps/shared/components/tasks/layout/TasksShell.js +92 -0
  144. package/dist/apps/shared/styles/app.css +265 -0
  145. package/dist/apps/www/_404.js +4 -0
  146. package/dist/apps/www/_500.js +4 -0
  147. package/dist/apps/www/_layout.js +8 -0
  148. package/dist/apps/www/calendar/index.js +228 -0
  149. package/dist/apps/www/contacts/[uid].js +62 -0
  150. package/dist/apps/www/contacts/index.js +262 -0
  151. package/dist/apps/www/index.js +228 -0
  152. package/dist/apps/www/messages/[uid].js +47 -0
  153. package/dist/apps/www/settings/auto-reply/index.js +54 -0
  154. package/dist/apps/www/settings/booking-types/[uid].js +129 -0
  155. package/dist/apps/www/settings/booking-types/index.js +29 -0
  156. package/dist/apps/www/settings/booking-types/new/index.js +86 -0
  157. package/dist/apps/www/settings/filters/[uid].js +94 -0
  158. package/dist/apps/www/settings/filters/_mailFilterRuleConfig.js +65 -0
  159. package/dist/apps/www/settings/filters/index.js +30 -0
  160. package/dist/apps/www/settings/filters/new/index.js +61 -0
  161. package/dist/apps/www/settings/focused-inbox/index.js +69 -0
  162. package/dist/apps/www/settings/read-receipts/index.js +51 -0
  163. package/dist/apps/www/settings/signatures/[uid].js +96 -0
  164. package/dist/apps/www/settings/signatures/index.js +34 -0
  165. package/dist/apps/www/settings/signatures/new/index.js +63 -0
  166. package/dist/apps/www/settings/signatures/signatureDefaults.js +30 -0
  167. package/dist/apps/www/tasks/index.js +280 -0
  168. package/package.json +87 -0
@@ -0,0 +1,518 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { ChangeEvent, useEffect, useRef, useState } from "react";
6
+ import {
7
+ HiOutlineArrowsPointingIn,
8
+ HiOutlineArrowsPointingOut,
9
+ HiOutlineChevronDown,
10
+ HiOutlineMinus,
11
+ HiOutlinePaperClip,
12
+ HiOutlineTrash,
13
+ HiOutlineXMark,
14
+ } from "react-icons/hi2";
15
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
16
+ import {
17
+ Attachment,
18
+ ComposeRecipientInput,
19
+ Message,
20
+ assembleDraft,
21
+ attachmentContentUrl,
22
+ createDraft,
23
+ listFolders,
24
+ sendMessage,
25
+ setMessageRequestReceipt,
26
+ setMessageScheduledSendTime,
27
+ uploadAttachment,
28
+ } from "@rapidmx/react-shared/mail/mailApi.js";
29
+ import { listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
30
+ import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
31
+ import type { ComposeSession } from "./ComposeContext.js";
32
+ import RichTextEditor from "./RichTextEditor.js";
33
+ import ScheduleSendPicker from "./ScheduleSendPicker.js";
34
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
35
+
36
+ export interface ComposeWindowProps {
37
+ session: ComposeSession;
38
+ onClose: () => void;
39
+ onToggleMinimize: () => void;
40
+ }
41
+
42
+ const FIELD_ROW = "flex items-center gap-2 px-3 py-1.5 border-b border-border";
43
+ const FIELD_INPUT = "flex-1 min-w-0 text-sm bg-transparent outline-none";
44
+
45
+ const MIN_WIDTH = 320;
46
+ const MIN_HEIGHT = 320;
47
+ // Kept well clear of the viewport edge, not flush against it — this is a resize *ceiling* (the drag
48
+ // handles below clamp to it), not the "expanded" preset's own size, which stays a plain `85vh`
49
+ // Tailwind class precisely so it never needs `window.innerHeight` at all: that'd have to be read at
50
+ // module-evaluation time to live alongside these other constants, and `window` doesn't exist yet
51
+ // during this app's SSR pass (see `ReactRoute`) — referencing it here would crash every page render,
52
+ // not just this component's.
53
+ const VIEWPORT_MARGIN = 24;
54
+
55
+ interface Size {
56
+ width: number;
57
+ height: number;
58
+ }
59
+
60
+ type ResizeEdge = "left" | "top" | "corner";
61
+
62
+ function parseAddresses(value: string): ComposeRecipientInput[] {
63
+ return value
64
+ .split(",")
65
+ .map((s) => s.trim())
66
+ .filter((s) => s.length > 0)
67
+ .map((address) => ({ address }));
68
+ }
69
+
70
+ function HeaderButton({ label, onClick, icon: Icon }: { label: string; onClick: () => void; icon: React.ComponentType<{ size?: number }> }) {
71
+ return (
72
+ <button
73
+ type="button"
74
+ aria-label={label}
75
+ title={label}
76
+ onClick={onClick}
77
+ className="w-6 h-6 flex items-center justify-center rounded-sm text-white/80 hover:bg-white/15 hover:text-white"
78
+ >
79
+ <Icon size={14} />
80
+ </button>
81
+ );
82
+ }
83
+
84
+ /**
85
+ * A floating, Gmail-style compose window — overlays whatever app is currently showing instead of
86
+ * navigating to a dedicated page (see `ComposeContext.tsx`'s doc comment for why: a full-page compose
87
+ * form meant losing your place in the inbox/calendar/contacts view behind it, and every navigation to
88
+ * `/compose` re-ran this whole app's full mailbox/folder resolution waterfall from scratch). Owns its
89
+ * own draft lifecycle end to end (unlike the old page, which read the Drafts folder from `MailShell`'s
90
+ * context — this window can be opened from apps that never mount `MailShell` at all, e.g. Contacts'
91
+ * "Email" action, so it resolves its own Drafts folder from just a `mailboxUid`).
92
+ *
93
+ * Below the `md` breakpoint, a non-minimized session instead renders as a full-screen sheet (there's no
94
+ * room for a floating window, and the click-drag resize handles below become meaningless full-screen) —
95
+ * see `ComposeContext.tsx`'s own doc comment for how that interacts with several sessions being open at
96
+ * once.
97
+ */
98
+ export default function ComposeWindow({ session, onClose, onToggleMinimize }: ComposeWindowProps) {
99
+ const { id, mailboxUid, initialTo, initialCc, initialSubject, initialQuotedHtml, signatureContext, minimized } = session;
100
+ const isMobile = useIsMobile();
101
+
102
+ const windowRef = useRef<HTMLDivElement>(null);
103
+ const [draftsFolderUid, setDraftsFolderUid] = useState<string | undefined>();
104
+ const [folderError, setFolderError] = useState<string | null>(null);
105
+ const [draft, setDraft] = useState<Message | null>(null);
106
+ const [draftError, setDraftError] = useState<string | null>(null);
107
+ const [expanded, setExpanded] = useState(false);
108
+ const [manualSize, setManualSize] = useState<Size | null>(null);
109
+ const [to, setTo] = useState(initialTo ?? "");
110
+ const [cc, setCc] = useState(initialCc ?? "");
111
+ const [bcc, setBcc] = useState("");
112
+ const [showCcBcc, setShowCcBcc] = useState(!!initialCc);
113
+ const [subject, setSubject] = useState(initialSubject ?? "");
114
+ const [html, setHtml] = useState("");
115
+ const [contentReady, setContentReady] = useState(false);
116
+ const [attachments, setAttachments] = useState<Attachment[]>([]);
117
+ const [attachError, setAttachError] = useState<string | null>(null);
118
+ const [sendError, setSendError] = useState<string | null>(null);
119
+ const [sending, setSending] = useState(false);
120
+ const [requestReceipt, setRequestReceipt] = useState(false);
121
+ const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
122
+ const scheduleButtonRef = useRef<HTMLButtonElement>(null);
123
+
124
+ useEffect(() => {
125
+ listFolders(mailboxUid)
126
+ .then((folders) => setDraftsFolderUid(folders.find((f) => f.type === "drafts")?.uid))
127
+ .catch((err) => setFolderError(err instanceof ApiRequestError ? err.message : "Could not load your Drafts folder."));
128
+ }, [mailboxUid]);
129
+
130
+ // Resolves the mailbox's default signature (if any) for `signatureContext` and seeds `html` with it
131
+ // plus any quoted original message, before `RichTextEditor` ever mounts (gated by `contentReady`
132
+ // below) — `RichTextEditor`'s own doc comment is explicit that `value` only seeds its *initial*
133
+ // content and never re-syncs from a later prop change, so this has to resolve before that first
134
+ // mount, not after. A signature-list failure is best-effort, same as every other supplementary,
135
+ // non-blocking fetch in this codebase (e.g. `ConversationThreadPane`'s attachment fetch) — no
136
+ // signature is a completely legitimate outcome, so this falls back to just the quoted content
137
+ // (if any) rather than surfacing an error over what's a cosmetic nicety.
138
+ useEffect(() => {
139
+ listMailSignatures(mailboxUid)
140
+ .then((signatures) => {
141
+ const signature = signatures.find((s) =>
142
+ signatureContext === "new" ? s.isDefaultForNewMessages : s.isDefaultForReplyForward,
143
+ );
144
+ const signatureHtml = signature?.contentHtml ? `${signature.contentHtml}<p></p>` : "";
145
+ setHtml(`${signatureHtml}${initialQuotedHtml ?? ""}`);
146
+ })
147
+ .catch(() => setHtml(initialQuotedHtml ?? ""))
148
+ .finally(() => setContentReady(true));
149
+ }, [mailboxUid, signatureContext, initialQuotedHtml]);
150
+
151
+ useEffect(() => {
152
+ if (!draftsFolderUid || draft) {
153
+ return;
154
+ }
155
+ createDraft(mailboxUid, draftsFolderUid)
156
+ .then(setDraft)
157
+ .catch((err) => setDraftError(err instanceof ApiRequestError ? err.message : "Could not start a new draft."));
158
+ }, [mailboxUid, draftsFolderUid, draft]);
159
+
160
+ /**
161
+ * Click-and-drag resize, from the window's own top/left edges (and the top-left corner, for both
162
+ * at once) — the window is anchored bottom-right (see `ComposeContext.tsx`'s stack), so growing it
163
+ * always means extending up and/or left, never down/right. `onMove`/`onUp` are fresh closures
164
+ * created per drag gesture (captured over this gesture's own start position/size) rather than
165
+ * stable component-level functions, specifically so `removeEventListener` in `onUp` always removes
166
+ * the exact listener `onMove`/`onUp` themselves just added — a version defined once per render and
167
+ * reused across gestures would need extra ref-juggling to avoid removing a stale (already-replaced)
168
+ * listener instead of the current one.
169
+ */
170
+ function handleResizeStart(edge: ResizeEdge) {
171
+ return (e: React.PointerEvent) => {
172
+ e.preventDefault();
173
+ const rect = windowRef.current!.getBoundingClientRect();
174
+ const startX = e.clientX;
175
+ const startY = e.clientY;
176
+ const startWidth = rect.width;
177
+ const startHeight = rect.height;
178
+
179
+ function onMove(ev: PointerEvent) {
180
+ const maxWidth = window.innerWidth - VIEWPORT_MARGIN;
181
+ const maxHeight = window.innerHeight - VIEWPORT_MARGIN;
182
+ const width = edge === "top" ? startWidth : Math.min(Math.max(startWidth - (ev.clientX - startX), MIN_WIDTH), maxWidth);
183
+ const height = edge === "left" ? startHeight : Math.min(Math.max(startHeight - (ev.clientY - startY), MIN_HEIGHT), maxHeight);
184
+ setManualSize({ width, height });
185
+ }
186
+ function onUp() {
187
+ window.removeEventListener("pointermove", onMove);
188
+ window.removeEventListener("pointerup", onUp);
189
+ }
190
+ window.addEventListener("pointermove", onMove);
191
+ window.addEventListener("pointerup", onUp);
192
+ };
193
+ }
194
+
195
+ function toggleExpanded() {
196
+ setExpanded((v) => !v);
197
+ setManualSize(null);
198
+ }
199
+
200
+ /** Uploads `file` as an attachment on the current draft, resolving to a URL the editor can preview
201
+ * it at immediately — see `BaseMailComposeRoute.rewriteInlineImageSources()`'s doc comment for how
202
+ * that URL gets swapped for the message's real inline `cid:` reference at send time. Resolves to
203
+ * `null` (surfacing `attachError` itself, reusing the same state "Attach files" already has) rather
204
+ * than throwing: unlike "Attach files" (`disabled={!draft}`), "Insert image" in the formatting
205
+ * toolbar is only gated on the *editor* being mounted, not on the draft existing yet — those two
206
+ * things load in parallel, so a real (if narrow) window exists where the button is clickable before
207
+ * `draft` resolves, and this needs to fail gracefully rather than crash on a null draft.
208
+ */
209
+ async function handleUploadImage(file: File): Promise<string | null> {
210
+ if (!draft) {
211
+ setAttachError("Please wait for the draft to finish loading before inserting an image.");
212
+ return null;
213
+ }
214
+ try {
215
+ const attachment = await uploadAttachment(draft.uid, file);
216
+ return attachmentContentUrl(attachment.uid);
217
+ } catch (err) {
218
+ setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload image.");
219
+ return null;
220
+ }
221
+ }
222
+
223
+ // The "Attach files" input is itself `disabled` until `draft` resolves (see its `disabled={!draft}`
224
+ // below), so this can only ever fire once `draft` is set — no defensive null check needed, matching
225
+ // this codebase's established pattern for the same class of "always non-null by the time it's
226
+ // called" value (e.g. `ComposeToolbar.run()`). No separate empty-`files` guard either: the loop
227
+ // below is already a no-op when there's nothing to iterate.
228
+ async function handleFilesSelected(e: ChangeEvent<HTMLInputElement>) {
229
+ const files = Array.from(e.target.files ?? []);
230
+ e.target.value = "";
231
+ setAttachError(null);
232
+ for (const file of files) {
233
+ try {
234
+ const attachment = await uploadAttachment(draft!.uid, file);
235
+ setAttachments((prev) => [...prev, attachment]);
236
+ } catch (err) {
237
+ setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
238
+ }
239
+ }
240
+ }
241
+
242
+ // Same reasoning as `handleFilesSelected` above: the Send button is itself `disabled` until `draft`
243
+ // resolves, so this is never reachable with a null `draft`.
244
+ async function handleSend() {
245
+ const toRecipients = parseAddresses(to);
246
+ if (toRecipients.length === 0) {
247
+ setSendError("At least one recipient is required.");
248
+ return;
249
+ }
250
+
251
+ setSending(true);
252
+ setSendError(null);
253
+ try {
254
+ // See the old compose page's identical note: `sanitize-html` is Node-oriented and the server-side
255
+ // gate in `BaseMailComposeRoute.assemble()` is the sole authoritative sanitizer regardless, so no
256
+ // client-side pass is done here either.
257
+ const assembled = await assembleDraft(draft!.uid, {
258
+ to: toRecipients,
259
+ cc: parseAddresses(cc),
260
+ bcc: parseAddresses(bcc),
261
+ subject,
262
+ html,
263
+ });
264
+ const withReceipt = requestReceipt ? await setMessageRequestReceipt(assembled, true) : assembled;
265
+ await sendMessage(withReceipt.uid);
266
+ onClose();
267
+ } catch (err) {
268
+ setSendError(err instanceof ApiRequestError ? err.message : "Could not send this message.");
269
+ } finally {
270
+ setSending(false);
271
+ }
272
+ }
273
+
274
+ // Same reasoning as `handleSend` above (and the "Send later" caret is itself `disabled` alongside
275
+ // Send until `draft` resolves) — never reachable with a null `draft`. Assembles the draft first
276
+ // (same as an immediate send), then sets `scheduledSendTime` on the *freshly assembled* copy before
277
+ // calling `sendMessage()` — `send()` itself is what reads the field and defers relay into Outbox
278
+ // instead of sending immediately (see `mailApi.ts`'s own doc comment on `setMessageScheduledSendTime`).
279
+ async function handleScheduleSend(scheduledSendTimeIso: string) {
280
+ const toRecipients = parseAddresses(to);
281
+ setSchedulePickerOpen(false);
282
+ if (toRecipients.length === 0) {
283
+ setSendError("At least one recipient is required.");
284
+ return;
285
+ }
286
+
287
+ setSending(true);
288
+ setSendError(null);
289
+ try {
290
+ const assembled = await assembleDraft(draft!.uid, {
291
+ to: toRecipients,
292
+ cc: parseAddresses(cc),
293
+ bcc: parseAddresses(bcc),
294
+ subject,
295
+ html,
296
+ });
297
+ const withReceipt = requestReceipt ? await setMessageRequestReceipt(assembled, true) : assembled;
298
+ const scheduled = await setMessageScheduledSendTime(withReceipt, scheduledSendTimeIso);
299
+ await sendMessage(scheduled.uid);
300
+ onClose();
301
+ } catch (err) {
302
+ setSendError(err instanceof ApiRequestError ? err.message : "Could not schedule this message.");
303
+ } finally {
304
+ setSending(false);
305
+ }
306
+ }
307
+
308
+ const title = subject.trim() || "New Message";
309
+ const titleId = `compose-title-${id}`;
310
+
311
+ if (minimized) {
312
+ return (
313
+ <div role="dialog" aria-label={title} className="w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal">
314
+ <div
315
+ className="h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer"
316
+ onClick={onToggleMinimize}
317
+ >
318
+ <span className="text-sm font-medium truncate">{title}</span>
319
+ <div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
320
+ <HeaderButton label="Restore" onClick={onToggleMinimize} icon={HiOutlineArrowsPointingOut} />
321
+ <HeaderButton
322
+ label="Discard draft"
323
+ onClick={onClose}
324
+ icon={HiOutlineXMark}
325
+ />
326
+ </div>
327
+ </div>
328
+ </div>
329
+ );
330
+ }
331
+
332
+ return (
333
+ <div
334
+ ref={windowRef}
335
+ role="dialog"
336
+ aria-labelledby={titleId}
337
+ style={!isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined}
338
+ className={[
339
+ "relative shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
340
+ isMobile
341
+ ? "fixed inset-0 w-full h-full rounded-none border-0"
342
+ : ["border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
343
+ ].join(" ")}
344
+ >
345
+ {!isMobile && (
346
+ <>
347
+ <div
348
+ onPointerDown={handleResizeStart("top")}
349
+ aria-hidden="true"
350
+ className="absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20"
351
+ />
352
+ <div
353
+ onPointerDown={handleResizeStart("left")}
354
+ aria-hidden="true"
355
+ className="absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20"
356
+ />
357
+ <div
358
+ onPointerDown={handleResizeStart("corner")}
359
+ role="separator"
360
+ aria-label="Resize"
361
+ className="absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30"
362
+ />
363
+ </>
364
+ )}
365
+
366
+ <div
367
+ className="h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer"
368
+ onClick={onToggleMinimize}
369
+ >
370
+ <span id={titleId} className="text-sm font-medium truncate">
371
+ {title}
372
+ </span>
373
+ <div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
374
+ <HeaderButton label="Minimize" onClick={onToggleMinimize} icon={HiOutlineMinus} />
375
+ {!isMobile && (
376
+ <HeaderButton
377
+ label={expanded ? "Collapse" : "Expand"}
378
+ onClick={toggleExpanded}
379
+ icon={expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut}
380
+ />
381
+ )}
382
+ <HeaderButton label="Close" onClick={onClose} icon={HiOutlineXMark} />
383
+ </div>
384
+ </div>
385
+
386
+ <div className="flex-1 min-h-0 flex flex-col">
387
+ {(folderError || draftError || sendError || attachError) && (
388
+ <div className="px-3 pt-2">
389
+ {folderError && <Alert>{folderError}</Alert>}
390
+ {draftError && <Alert>{draftError}</Alert>}
391
+ {sendError && <Alert>{sendError}</Alert>}
392
+ {attachError && <Alert>{attachError}</Alert>}
393
+ </div>
394
+ )}
395
+
396
+ <div className={FIELD_ROW}>
397
+ <label htmlFor={`compose-to-${id}`} className="text-xs text-text-muted shrink-0">
398
+ To
399
+ </label>
400
+ <input
401
+ id={`compose-to-${id}`}
402
+ type="text"
403
+ className={FIELD_INPUT}
404
+ value={to}
405
+ onChange={(e) => setTo(e.target.value)}
406
+ />
407
+ {!showCcBcc && (
408
+ <button
409
+ type="button"
410
+ onClick={() => setShowCcBcc(true)}
411
+ className="text-xs text-text-muted hover:text-text shrink-0"
412
+ >
413
+ Cc Bcc
414
+ </button>
415
+ )}
416
+ </div>
417
+
418
+ {showCcBcc && (
419
+ <>
420
+ <div className={FIELD_ROW}>
421
+ <label htmlFor={`compose-cc-${id}`} className="text-xs text-text-muted shrink-0">
422
+ Cc
423
+ </label>
424
+ <input id={`compose-cc-${id}`} type="text" className={FIELD_INPUT} value={cc} onChange={(e) => setCc(e.target.value)} />
425
+ </div>
426
+ <div className={FIELD_ROW}>
427
+ <label htmlFor={`compose-bcc-${id}`} className="text-xs text-text-muted shrink-0">
428
+ Bcc
429
+ </label>
430
+ <input id={`compose-bcc-${id}`} type="text" className={FIELD_INPUT} value={bcc} onChange={(e) => setBcc(e.target.value)} />
431
+ </div>
432
+ </>
433
+ )}
434
+
435
+ <div className={FIELD_ROW}>
436
+ <input
437
+ aria-label="Subject"
438
+ type="text"
439
+ placeholder="Subject"
440
+ className={FIELD_INPUT}
441
+ value={subject}
442
+ onChange={(e) => setSubject(e.target.value)}
443
+ />
444
+ </div>
445
+
446
+ <div className="flex-1 min-h-0 p-2">
447
+ {contentReady && <RichTextEditor value={html} onChange={setHtml} fill onUploadImage={handleUploadImage} />}
448
+ </div>
449
+
450
+ {attachments.length > 0 && (
451
+ <ul className="flex flex-wrap gap-2 px-3 pb-2">
452
+ {attachments.map((attachment) => (
453
+ <li key={attachment.uid} className="text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted">
454
+ {attachment.filename}
455
+ </li>
456
+ ))}
457
+ </ul>
458
+ )}
459
+
460
+ <label className="flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted">
461
+ <input type="checkbox" checked={requestReceipt} onChange={(e) => setRequestReceipt(e.target.checked)} />
462
+ Request a read receipt
463
+ </label>
464
+
465
+ <div className="shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border">
466
+ <div className="flex items-center rounded-pill bg-primary text-white overflow-hidden">
467
+ <button
468
+ type="button"
469
+ onClick={handleSend}
470
+ disabled={!draft || sending}
471
+ 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"
472
+ >
473
+ {sending ? "Sending…" : "Send"}
474
+ </button>
475
+ <button
476
+ ref={scheduleButtonRef}
477
+ type="button"
478
+ aria-label="Send later"
479
+ aria-haspopup="true"
480
+ aria-expanded={schedulePickerOpen}
481
+ disabled={!draft || sending}
482
+ onClick={() => setSchedulePickerOpen((o) => !o)}
483
+ className="py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed"
484
+ >
485
+ <HiOutlineChevronDown size={14} />
486
+ </button>
487
+ </div>
488
+ {schedulePickerOpen && (
489
+ <ScheduleSendPicker
490
+ anchorRef={scheduleButtonRef}
491
+ onClose={() => setSchedulePickerOpen(false)}
492
+ onSchedule={handleScheduleSend}
493
+ />
494
+ )}
495
+
496
+ <label
497
+ aria-label="Attach files"
498
+ title="Attach files"
499
+ 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"
500
+ >
501
+ <HiOutlinePaperClip size={18} />
502
+ <input type="file" multiple disabled={!draft} onChange={handleFilesSelected} className="sr-only" />
503
+ </label>
504
+
505
+ <button
506
+ type="button"
507
+ aria-label="Discard draft"
508
+ title="Discard draft"
509
+ onClick={onClose}
510
+ className="ml-auto w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text"
511
+ >
512
+ <HiOutlineTrash size={18} />
513
+ </button>
514
+ </div>
515
+ </div>
516
+ </div>
517
+ );
518
+ }
@@ -0,0 +1,64 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { RefObject } from "react";
6
+ import { EMOJI_CATEGORIES } from "@rapidmx/react-shared/mail/compose/emojiData.js";
7
+ import PopoverPortal from "@rapidmx/react-shared/components/overlays/PopoverPortal.js";
8
+
9
+ export interface EmojiPickerProps {
10
+ anchorRef: RefObject<HTMLElement | null>;
11
+ onSelect: (native: string) => void;
12
+ onClose: () => void;
13
+ }
14
+
15
+ const CATEGORY_LABELS: Record<string, string> = {
16
+ people: "Smileys & People",
17
+ nature: "Animals & Nature",
18
+ foods: "Food & Drink",
19
+ activity: "Activity",
20
+ places: "Travel & Places",
21
+ objects: "Objects",
22
+ symbols: "Symbols",
23
+ flags: "Flags",
24
+ };
25
+
26
+ const WIDTH = 288;
27
+ const HEIGHT = 320;
28
+
29
+ /**
30
+ * "Insert emoji" popup — a scrollable grid of every standard unicode emoji, grouped by category (see
31
+ * `emojiData.ts`). Deliberately no search box, unlike `GifPicker`'s — the user's own request only
32
+ * asked for a browsable grid here, and 8 category headers over ~1900 emojis is already a reasonably
33
+ * navigable amount without one. Rendered via `PopoverPortal` — see that component's own doc comment
34
+ * on why (escaping the toolbar's `overflow-hidden` ancestors).
35
+ */
36
+ export default function EmojiPicker({ anchorRef, onSelect, onClose }: EmojiPickerProps) {
37
+ return (
38
+ <PopoverPortal anchorRef={anchorRef} onClose={onClose} width={WIDTH} height={HEIGHT} aria-label="Insert emoji">
39
+ <div className="flex-1 overflow-y-auto p-2">
40
+ {EMOJI_CATEGORIES.map((category) => (
41
+ <div key={category.id} className="mb-2">
42
+ <h3 className="text-[11px] font-bold uppercase tracking-wide text-text-muted px-1 mb-1 sticky top-0 bg-surface">
43
+ {CATEGORY_LABELS[category.id] ?? category.id}
44
+ </h3>
45
+ <div className="grid grid-cols-8 gap-0.5">
46
+ {category.emojis.map((emoji) => (
47
+ <button
48
+ key={emoji.id}
49
+ type="button"
50
+ title={emoji.name}
51
+ aria-label={emoji.name}
52
+ onClick={() => onSelect(emoji.native)}
53
+ className="w-8 h-8 flex items-center justify-center text-lg rounded-sm hover:bg-surface-alt"
54
+ >
55
+ {emoji.native}
56
+ </button>
57
+ ))}
58
+ </div>
59
+ </div>
60
+ ))}
61
+ </div>
62
+ </PopoverPortal>
63
+ );
64
+ }
@@ -0,0 +1,82 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { RefObject, useEffect, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { GiphyGif, searchGifs } from "@rapidmx/react-shared/mail/compose/giphyApi.js";
8
+ import PopoverPortal from "@rapidmx/react-shared/components/overlays/PopoverPortal.js";
9
+
10
+ export interface GifPickerProps {
11
+ anchorRef: RefObject<HTMLElement | null>;
12
+ onSelect: (url: string) => void;
13
+ onClose: () => void;
14
+ }
15
+
16
+ const SEARCH_DEBOUNCE_MS = 350;
17
+ const WIDTH = 288;
18
+ const HEIGHT = 320;
19
+
20
+ /** "Insert GIF" popup — a search box plus a scrollable grid of Giphy thumbnails (trending by default,
21
+ * live-searched as the user types). Selecting one inserts its full-resolution URL into the message
22
+ * body — see `ComposeToolbar.tsx`'s own doc comment on why that's a plain `<img>` reference rather
23
+ * than an uploaded attachment (unlike "Insert image"): a GIF is already hosted at a stable public
24
+ * URL, so there's nothing to upload. Rendered via `PopoverPortal` — see that component's own doc
25
+ * comment on why (escaping the toolbar's `overflow-hidden` ancestors). */
26
+ export default function GifPicker({ anchorRef, onSelect, onClose }: GifPickerProps) {
27
+ const [query, setQuery] = useState("");
28
+ const [gifs, setGifs] = useState<GiphyGif[]>([]);
29
+ const [loading, setLoading] = useState(true);
30
+ const [error, setError] = useState<string | null>(null);
31
+
32
+ useEffect(() => {
33
+ setLoading(true);
34
+ setError(null);
35
+ const handle = setTimeout(() => {
36
+ searchGifs(query)
37
+ .then(setGifs)
38
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load GIFs."))
39
+ .finally(() => setLoading(false));
40
+ }, SEARCH_DEBOUNCE_MS);
41
+ return () => clearTimeout(handle);
42
+ }, [query]);
43
+
44
+ return (
45
+ <PopoverPortal anchorRef={anchorRef} onClose={onClose} width={WIDTH} height={HEIGHT} aria-label="Insert GIF">
46
+ <div className="p-2 border-b border-border shrink-0">
47
+ <input
48
+ type="text"
49
+ autoFocus
50
+ aria-label="Search GIFs"
51
+ placeholder="Search GIFs"
52
+ value={query}
53
+ onChange={(e) => setQuery(e.target.value)}
54
+ className="w-full text-sm py-1.5 px-2 border border-border rounded-sm bg-surface focus:outline-none focus:border-primary"
55
+ />
56
+ </div>
57
+ <div className="flex-1 overflow-y-auto p-2">
58
+ {error ? (
59
+ <p className="text-xs text-danger">{error}</p>
60
+ ) : loading ? (
61
+ <p className="text-xs text-text-muted">Loading&hellip;</p>
62
+ ) : gifs.length === 0 ? (
63
+ <p className="text-xs text-text-muted">No GIFs found.</p>
64
+ ) : (
65
+ <div className="grid grid-cols-2 gap-1.5">
66
+ {gifs.map((gif) => (
67
+ <button
68
+ key={gif.id}
69
+ type="button"
70
+ title={gif.title}
71
+ onClick={() => onSelect(gif.url)}
72
+ className="rounded-sm overflow-hidden hover:opacity-80"
73
+ >
74
+ <img src={gif.previewUrl} alt={gif.title} className="w-full h-20 object-cover" />
75
+ </button>
76
+ ))}
77
+ </div>
78
+ )}
79
+ </div>
80
+ </PopoverPortal>
81
+ );
82
+ }