@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,88 @@
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 { useRef, useState } from "react";
7
+ import { BsArrowClockwise, BsArrowCounterclockwise, BsEraser, BsHighlighter, BsImage, BsIndent, BsJustify, BsLink45Deg, BsListOl, BsListUl, BsPaletteFill, BsTable, BsTextCenter, BsTextLeft, BsTextRight, BsTypeBold, BsTypeItalic, BsTypeStrikethrough, BsTypeUnderline, BsUnindent, } from "react-icons/bs";
8
+ import EmojiPicker from "./EmojiPicker.js";
9
+ import GifPicker from "./GifPicker.js";
10
+ const FONT_FAMILIES = [
11
+ { label: "Default", value: "" },
12
+ { label: "Sans Serif", value: "Arial, Helvetica, sans-serif" },
13
+ { label: "Serif", value: "Georgia, 'Times New Roman', serif" },
14
+ { label: "Monospace", value: "'Courier New', monospace" },
15
+ ];
16
+ const FONT_SIZES = [
17
+ { label: "Small", value: "12px" },
18
+ { label: "Normal", value: "" },
19
+ { label: "Large", value: "18px" },
20
+ { label: "Huge", value: "24px" },
21
+ ];
22
+ function ToolbarButton({ label, icon: Icon, active, disabled, onClick, }) {
23
+ return (_jsx("button", { type: "button", "aria-label": label, "aria-pressed": active ?? false, title: label, disabled: disabled, onClick: onClick, className: [
24
+ "w-8 h-8 flex items-center justify-center rounded-sm disabled:opacity-40 disabled:cursor-not-allowed",
25
+ active ? "bg-primary/10 text-primary-dark" : "text-text-muted hover:bg-surface-alt hover:text-text",
26
+ ].join(" "), children: _jsx(Icon, { size: 16, "aria-hidden": "true" }) }));
27
+ }
28
+ function Divider() {
29
+ return _jsx("div", { className: "w-px self-stretch my-1.5 bg-border", "aria-hidden": "true" });
30
+ }
31
+ export default function ComposeToolbar({ editor, onUploadImage }) {
32
+ const [openPopup, setOpenPopup] = useState(null);
33
+ const [linkUrl, setLinkUrl] = useState("");
34
+ const imageInputRef = useRef(null);
35
+ const emojiButtonRef = useRef(null);
36
+ const gifButtonRef = useRef(null);
37
+ const disabled = !editor;
38
+ // Every control that calls `run()` is itself `disabled` while `editor` is null (see `disabled` above),
39
+ // and `editor` never reverts to null once TipTap actually creates it (only `immediatelyRender: false`'s
40
+ // one-render gap before mount produces a null `editor` at all) — so `run()` is never reachable with a
41
+ // null `editor` through any real UI interaction; asserted non-null rather than guarded, matching this
42
+ // codebase's established pattern for the same class of "always non-null by the time it's called" value.
43
+ function run(fn) {
44
+ fn(editor);
45
+ }
46
+ async function handleImageFileSelected(e) {
47
+ const file = e.target.files?.[0];
48
+ e.target.value = "";
49
+ if (!file) {
50
+ return;
51
+ }
52
+ const url = await onUploadImage(file);
53
+ if (url) {
54
+ run((ed) => ed.chain().focus().setImage({ src: url }).run());
55
+ }
56
+ }
57
+ function handleInsertEmoji(native) {
58
+ run((e) => e.chain().focus().insertContent(native).run());
59
+ setOpenPopup(null);
60
+ }
61
+ function handleInsertGif(url) {
62
+ run((e) => e.chain().focus().setImage({ src: url }).run());
63
+ setOpenPopup(null);
64
+ }
65
+ function openLinkPrompt() {
66
+ setLinkUrl(editor?.getAttributes("link").href ?? "");
67
+ setOpenPopup("link");
68
+ }
69
+ function submitLink(e) {
70
+ e.preventDefault();
71
+ if (linkUrl.trim().length === 0) {
72
+ run((ed) => ed.chain().focus().extendMarkRange("link").unsetLink().run());
73
+ }
74
+ else {
75
+ run((ed) => ed.chain().focus().extendMarkRange("link").setLink({ href: linkUrl.trim() }).run());
76
+ }
77
+ setOpenPopup(null);
78
+ }
79
+ let linkPrompt = null;
80
+ if (openPopup === "link") {
81
+ linkPrompt = (_jsxs("form", { onSubmit: submitLink, className: "flex items-center gap-1.5 px-2 py-1.5 border-t border-border", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "Link URL", placeholder: "https://example.com", value: linkUrl, onChange: (e) => setLinkUrl(e.target.value), className: "flex-1 text-sm py-1 px-2 border border-border rounded-sm bg-surface" }), _jsx("button", { type: "submit", className: "text-xs font-semibold text-primary-dark hover:underline", children: "Apply" }), _jsx("button", { type: "button", onClick: () => setOpenPopup(null), className: "text-xs font-medium text-text-muted hover:underline", children: "Cancel" })] }));
82
+ }
83
+ return (_jsxs("div", { className: "border-b border-border bg-surface-alt", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-0.5 px-2 py-1.5", children: [_jsx("select", { "aria-label": "Font family", disabled: disabled, value: editor?.getAttributes("textStyle").fontFamily ?? "", onChange: (e) => run((ed) => e.target.value
84
+ ? ed.chain().focus().setFontFamily(e.target.value).run()
85
+ : ed.chain().focus().unsetFontFamily().run()), className: "text-xs py-1 px-1.5 border border-border rounded-sm bg-surface disabled:opacity-40", children: FONT_FAMILIES.map((f) => (_jsx("option", { value: f.value, children: f.label }, f.label))) }), _jsx("select", { "aria-label": "Font size", disabled: disabled, value: editor?.getAttributes("textStyle").fontSize ?? "", onChange: (e) => run((ed) => e.target.value
86
+ ? ed.chain().focus().setFontSize(e.target.value).run()
87
+ : ed.chain().focus().unsetFontSize().run()), className: "text-xs py-1 px-1.5 border border-border rounded-sm bg-surface disabled:opacity-40", children: FONT_SIZES.map((f) => (_jsx("option", { value: f.value, children: f.label }, f.label))) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Bold", icon: BsTypeBold, disabled: disabled, active: editor?.isActive("bold"), onClick: () => run((e) => e.chain().focus().toggleBold().run()) }), _jsx(ToolbarButton, { label: "Italic", icon: BsTypeItalic, disabled: disabled, active: editor?.isActive("italic"), onClick: () => run((e) => e.chain().focus().toggleItalic().run()) }), _jsx(ToolbarButton, { label: "Underline", icon: BsTypeUnderline, disabled: disabled, active: editor?.isActive("underline"), onClick: () => run((e) => e.chain().focus().toggleUnderline().run()) }), _jsx(ToolbarButton, { label: "Strikethrough", icon: BsTypeStrikethrough, disabled: disabled, active: editor?.isActive("strike"), onClick: () => run((e) => e.chain().focus().toggleStrike().run()) }), _jsx(Divider, {}), _jsxs("label", { className: "w-8 h-8 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed", children: [_jsx(BsPaletteFill, { size: 16, "aria-hidden": "true" }), _jsx("span", { className: "sr-only", children: "Text color" }), _jsx("input", { type: "color", disabled: disabled, value: editor?.getAttributes("textStyle").color ?? "#000000", onChange: (e) => run((ed) => ed.chain().focus().setColor(e.target.value).run()), className: "sr-only" })] }), _jsx(ToolbarButton, { label: "Highlight", icon: BsHighlighter, disabled: disabled, active: editor?.isActive("highlight"), onClick: () => run((e) => e.chain().focus().toggleHighlight().run()) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Align left", icon: BsTextLeft, disabled: disabled, active: editor?.isActive({ textAlign: "left" }), onClick: () => run((e) => e.chain().focus().setTextAlign("left").run()) }), _jsx(ToolbarButton, { label: "Align center", icon: BsTextCenter, disabled: disabled, active: editor?.isActive({ textAlign: "center" }), onClick: () => run((e) => e.chain().focus().setTextAlign("center").run()) }), _jsx(ToolbarButton, { label: "Align right", icon: BsTextRight, disabled: disabled, active: editor?.isActive({ textAlign: "right" }), onClick: () => run((e) => e.chain().focus().setTextAlign("right").run()) }), _jsx(ToolbarButton, { label: "Justify", icon: BsJustify, disabled: disabled, active: editor?.isActive({ textAlign: "justify" }), onClick: () => run((e) => e.chain().focus().setTextAlign("justify").run()) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Bulleted list", icon: BsListUl, disabled: disabled, active: editor?.isActive("bulletList"), onClick: () => run((e) => e.chain().focus().toggleBulletList().run()) }), _jsx(ToolbarButton, { label: "Numbered list", icon: BsListOl, disabled: disabled, active: editor?.isActive("orderedList"), onClick: () => run((e) => e.chain().focus().toggleOrderedList().run()) }), _jsx(ToolbarButton, { label: "Decrease indent", icon: BsUnindent, disabled: disabled, onClick: () => run((e) => e.chain().focus().liftListItem("listItem").run()) }), _jsx(ToolbarButton, { label: "Increase indent", icon: BsIndent, disabled: disabled, onClick: () => run((e) => e.chain().focus().sinkListItem("listItem").run()) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Insert link", icon: BsLink45Deg, disabled: disabled, active: editor?.isActive("link"), onClick: openLinkPrompt }), _jsx(ToolbarButton, { label: "Insert image", icon: BsImage, disabled: disabled, onClick: () => imageInputRef.current?.click() }), _jsx("input", { ref: imageInputRef, type: "file", accept: "image/*", onChange: handleImageFileSelected, className: "sr-only", "aria-label": "Insert image file" }), _jsx(ToolbarButton, { label: "Insert table", icon: BsTable, disabled: disabled, onClick: () => run((e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()) }), _jsx("button", { ref: emojiButtonRef, type: "button", "aria-label": "Insert emoji", title: "Insert emoji", disabled: disabled, onClick: () => setOpenPopup((p) => (p === "emoji" ? null : "emoji")), className: "w-8 h-8 flex items-center justify-center text-base rounded-sm disabled:opacity-40 disabled:cursor-not-allowed text-text-muted hover:bg-surface-alt hover:text-text", children: "\uD83D\uDE00" }), openPopup === "emoji" && (_jsx(EmojiPicker, { anchorRef: emojiButtonRef, onSelect: handleInsertEmoji, onClose: () => setOpenPopup(null) })), _jsx("button", { ref: gifButtonRef, type: "button", "aria-label": "Insert GIF", title: "Insert GIF", disabled: disabled, onClick: () => setOpenPopup((p) => (p === "gif" ? null : "gif")), className: "w-8 h-8 flex items-center justify-center text-[10px] font-extrabold tracking-tight rounded-sm disabled:opacity-40 disabled:cursor-not-allowed text-text-muted hover:bg-surface-alt hover:text-text", children: "GIF" }), openPopup === "gif" && _jsx(GifPicker, { anchorRef: gifButtonRef, onSelect: handleInsertGif, onClose: () => setOpenPopup(null) }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Clear formatting", icon: BsEraser, disabled: disabled, onClick: () => run((e) => e.chain().focus().unsetAllMarks().clearNodes().run()) }), _jsx(ToolbarButton, { label: "Undo", icon: BsArrowCounterclockwise, disabled: disabled || !editor?.can().undo(), onClick: () => run((e) => e.chain().focus().undo().run()) }), _jsx(ToolbarButton, { label: "Redo", icon: BsArrowClockwise, disabled: disabled || !editor?.can().redo(), onClick: () => run((e) => e.chain().focus().redo().run()) })] }), linkPrompt] }));
88
+ }
@@ -0,0 +1,261 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useRef, useState } from "react";
7
+ import { HiOutlineArrowsPointingIn, HiOutlineArrowsPointingOut, HiOutlineChevronDown, HiOutlineMinus, HiOutlinePaperClip, HiOutlineTrash, HiOutlineXMark, } from "react-icons/hi2";
8
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
+ import { assembleDraft, attachmentContentUrl, createDraft, listFolders, sendMessage, setMessageRequestReceipt, setMessageScheduledSendTime, uploadAttachment, } from "@rapidmx/react-shared/mail/mailApi.js";
10
+ import { listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
11
+ import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
12
+ import RichTextEditor from "./RichTextEditor.js";
13
+ import ScheduleSendPicker from "./ScheduleSendPicker.js";
14
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
15
+ const FIELD_ROW = "flex items-center gap-2 px-3 py-1.5 border-b border-border";
16
+ const FIELD_INPUT = "flex-1 min-w-0 text-sm bg-transparent outline-none";
17
+ const MIN_WIDTH = 320;
18
+ const MIN_HEIGHT = 320;
19
+ // Kept well clear of the viewport edge, not flush against it — this is a resize *ceiling* (the drag
20
+ // handles below clamp to it), not the "expanded" preset's own size, which stays a plain `85vh`
21
+ // Tailwind class precisely so it never needs `window.innerHeight` at all: that'd have to be read at
22
+ // module-evaluation time to live alongside these other constants, and `window` doesn't exist yet
23
+ // during this app's SSR pass (see `ReactRoute`) — referencing it here would crash every page render,
24
+ // not just this component's.
25
+ const VIEWPORT_MARGIN = 24;
26
+ function parseAddresses(value) {
27
+ return value
28
+ .split(",")
29
+ .map((s) => s.trim())
30
+ .filter((s) => s.length > 0)
31
+ .map((address) => ({ address }));
32
+ }
33
+ function HeaderButton({ label, onClick, icon: Icon }) {
34
+ return (_jsx("button", { type: "button", "aria-label": label, title: label, onClick: onClick, className: "w-6 h-6 flex items-center justify-center rounded-sm text-white/80 hover:bg-white/15 hover:text-white", children: _jsx(Icon, { size: 14 }) }));
35
+ }
36
+ /**
37
+ * A floating, Gmail-style compose window — overlays whatever app is currently showing instead of
38
+ * navigating to a dedicated page (see `ComposeContext.tsx`'s doc comment for why: a full-page compose
39
+ * form meant losing your place in the inbox/calendar/contacts view behind it, and every navigation to
40
+ * `/compose` re-ran this whole app's full mailbox/folder resolution waterfall from scratch). Owns its
41
+ * own draft lifecycle end to end (unlike the old page, which read the Drafts folder from `MailShell`'s
42
+ * context — this window can be opened from apps that never mount `MailShell` at all, e.g. Contacts'
43
+ * "Email" action, so it resolves its own Drafts folder from just a `mailboxUid`).
44
+ *
45
+ * Below the `md` breakpoint, a non-minimized session instead renders as a full-screen sheet (there's no
46
+ * room for a floating window, and the click-drag resize handles below become meaningless full-screen) —
47
+ * see `ComposeContext.tsx`'s own doc comment for how that interacts with several sessions being open at
48
+ * once.
49
+ */
50
+ export default function ComposeWindow({ session, onClose, onToggleMinimize }) {
51
+ const { id, mailboxUid, initialTo, initialCc, initialSubject, initialQuotedHtml, signatureContext, minimized } = session;
52
+ const isMobile = useIsMobile();
53
+ const windowRef = useRef(null);
54
+ const [draftsFolderUid, setDraftsFolderUid] = useState();
55
+ const [folderError, setFolderError] = useState(null);
56
+ const [draft, setDraft] = useState(null);
57
+ const [draftError, setDraftError] = useState(null);
58
+ const [expanded, setExpanded] = useState(false);
59
+ const [manualSize, setManualSize] = useState(null);
60
+ const [to, setTo] = useState(initialTo ?? "");
61
+ const [cc, setCc] = useState(initialCc ?? "");
62
+ const [bcc, setBcc] = useState("");
63
+ const [showCcBcc, setShowCcBcc] = useState(!!initialCc);
64
+ const [subject, setSubject] = useState(initialSubject ?? "");
65
+ const [html, setHtml] = useState("");
66
+ const [contentReady, setContentReady] = useState(false);
67
+ const [attachments, setAttachments] = useState([]);
68
+ const [attachError, setAttachError] = useState(null);
69
+ const [sendError, setSendError] = useState(null);
70
+ const [sending, setSending] = useState(false);
71
+ const [requestReceipt, setRequestReceipt] = useState(false);
72
+ const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
73
+ const scheduleButtonRef = useRef(null);
74
+ useEffect(() => {
75
+ listFolders(mailboxUid)
76
+ .then((folders) => setDraftsFolderUid(folders.find((f) => f.type === "drafts")?.uid))
77
+ .catch((err) => setFolderError(err instanceof ApiRequestError ? err.message : "Could not load your Drafts folder."));
78
+ }, [mailboxUid]);
79
+ // Resolves the mailbox's default signature (if any) for `signatureContext` and seeds `html` with it
80
+ // plus any quoted original message, before `RichTextEditor` ever mounts (gated by `contentReady`
81
+ // below) — `RichTextEditor`'s own doc comment is explicit that `value` only seeds its *initial*
82
+ // content and never re-syncs from a later prop change, so this has to resolve before that first
83
+ // mount, not after. A signature-list failure is best-effort, same as every other supplementary,
84
+ // non-blocking fetch in this codebase (e.g. `ConversationThreadPane`'s attachment fetch) — no
85
+ // signature is a completely legitimate outcome, so this falls back to just the quoted content
86
+ // (if any) rather than surfacing an error over what's a cosmetic nicety.
87
+ useEffect(() => {
88
+ listMailSignatures(mailboxUid)
89
+ .then((signatures) => {
90
+ const signature = signatures.find((s) => signatureContext === "new" ? s.isDefaultForNewMessages : s.isDefaultForReplyForward);
91
+ const signatureHtml = signature?.contentHtml ? `${signature.contentHtml}<p></p>` : "";
92
+ setHtml(`${signatureHtml}${initialQuotedHtml ?? ""}`);
93
+ })
94
+ .catch(() => setHtml(initialQuotedHtml ?? ""))
95
+ .finally(() => setContentReady(true));
96
+ }, [mailboxUid, signatureContext, initialQuotedHtml]);
97
+ useEffect(() => {
98
+ if (!draftsFolderUid || draft) {
99
+ return;
100
+ }
101
+ createDraft(mailboxUid, draftsFolderUid)
102
+ .then(setDraft)
103
+ .catch((err) => setDraftError(err instanceof ApiRequestError ? err.message : "Could not start a new draft."));
104
+ }, [mailboxUid, draftsFolderUid, draft]);
105
+ /**
106
+ * Click-and-drag resize, from the window's own top/left edges (and the top-left corner, for both
107
+ * at once) — the window is anchored bottom-right (see `ComposeContext.tsx`'s stack), so growing it
108
+ * always means extending up and/or left, never down/right. `onMove`/`onUp` are fresh closures
109
+ * created per drag gesture (captured over this gesture's own start position/size) rather than
110
+ * stable component-level functions, specifically so `removeEventListener` in `onUp` always removes
111
+ * the exact listener `onMove`/`onUp` themselves just added — a version defined once per render and
112
+ * reused across gestures would need extra ref-juggling to avoid removing a stale (already-replaced)
113
+ * listener instead of the current one.
114
+ */
115
+ function handleResizeStart(edge) {
116
+ return (e) => {
117
+ e.preventDefault();
118
+ const rect = windowRef.current.getBoundingClientRect();
119
+ const startX = e.clientX;
120
+ const startY = e.clientY;
121
+ const startWidth = rect.width;
122
+ const startHeight = rect.height;
123
+ function onMove(ev) {
124
+ const maxWidth = window.innerWidth - VIEWPORT_MARGIN;
125
+ const maxHeight = window.innerHeight - VIEWPORT_MARGIN;
126
+ const width = edge === "top" ? startWidth : Math.min(Math.max(startWidth - (ev.clientX - startX), MIN_WIDTH), maxWidth);
127
+ const height = edge === "left" ? startHeight : Math.min(Math.max(startHeight - (ev.clientY - startY), MIN_HEIGHT), maxHeight);
128
+ setManualSize({ width, height });
129
+ }
130
+ function onUp() {
131
+ window.removeEventListener("pointermove", onMove);
132
+ window.removeEventListener("pointerup", onUp);
133
+ }
134
+ window.addEventListener("pointermove", onMove);
135
+ window.addEventListener("pointerup", onUp);
136
+ };
137
+ }
138
+ function toggleExpanded() {
139
+ setExpanded((v) => !v);
140
+ setManualSize(null);
141
+ }
142
+ /** Uploads `file` as an attachment on the current draft, resolving to a URL the editor can preview
143
+ * it at immediately — see `BaseMailComposeRoute.rewriteInlineImageSources()`'s doc comment for how
144
+ * that URL gets swapped for the message's real inline `cid:` reference at send time. Resolves to
145
+ * `null` (surfacing `attachError` itself, reusing the same state "Attach files" already has) rather
146
+ * than throwing: unlike "Attach files" (`disabled={!draft}`), "Insert image" in the formatting
147
+ * toolbar is only gated on the *editor* being mounted, not on the draft existing yet — those two
148
+ * things load in parallel, so a real (if narrow) window exists where the button is clickable before
149
+ * `draft` resolves, and this needs to fail gracefully rather than crash on a null draft.
150
+ */
151
+ async function handleUploadImage(file) {
152
+ if (!draft) {
153
+ setAttachError("Please wait for the draft to finish loading before inserting an image.");
154
+ return null;
155
+ }
156
+ try {
157
+ const attachment = await uploadAttachment(draft.uid, file);
158
+ return attachmentContentUrl(attachment.uid);
159
+ }
160
+ catch (err) {
161
+ setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload image.");
162
+ return null;
163
+ }
164
+ }
165
+ // The "Attach files" input is itself `disabled` until `draft` resolves (see its `disabled={!draft}`
166
+ // below), so this can only ever fire once `draft` is set — no defensive null check needed, matching
167
+ // this codebase's established pattern for the same class of "always non-null by the time it's
168
+ // called" value (e.g. `ComposeToolbar.run()`). No separate empty-`files` guard either: the loop
169
+ // below is already a no-op when there's nothing to iterate.
170
+ async function handleFilesSelected(e) {
171
+ const files = Array.from(e.target.files ?? []);
172
+ e.target.value = "";
173
+ setAttachError(null);
174
+ for (const file of files) {
175
+ try {
176
+ const attachment = await uploadAttachment(draft.uid, file);
177
+ setAttachments((prev) => [...prev, attachment]);
178
+ }
179
+ catch (err) {
180
+ setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
181
+ }
182
+ }
183
+ }
184
+ // Same reasoning as `handleFilesSelected` above: the Send button is itself `disabled` until `draft`
185
+ // resolves, so this is never reachable with a null `draft`.
186
+ async function handleSend() {
187
+ const toRecipients = parseAddresses(to);
188
+ if (toRecipients.length === 0) {
189
+ setSendError("At least one recipient is required.");
190
+ return;
191
+ }
192
+ setSending(true);
193
+ setSendError(null);
194
+ try {
195
+ // See the old compose page's identical note: `sanitize-html` is Node-oriented and the server-side
196
+ // gate in `BaseMailComposeRoute.assemble()` is the sole authoritative sanitizer regardless, so no
197
+ // client-side pass is done here either.
198
+ const assembled = await assembleDraft(draft.uid, {
199
+ to: toRecipients,
200
+ cc: parseAddresses(cc),
201
+ bcc: parseAddresses(bcc),
202
+ subject,
203
+ html,
204
+ });
205
+ const withReceipt = requestReceipt ? await setMessageRequestReceipt(assembled, true) : assembled;
206
+ await sendMessage(withReceipt.uid);
207
+ onClose();
208
+ }
209
+ catch (err) {
210
+ setSendError(err instanceof ApiRequestError ? err.message : "Could not send this message.");
211
+ }
212
+ finally {
213
+ setSending(false);
214
+ }
215
+ }
216
+ // Same reasoning as `handleSend` above (and the "Send later" caret is itself `disabled` alongside
217
+ // Send until `draft` resolves) — never reachable with a null `draft`. Assembles the draft first
218
+ // (same as an immediate send), then sets `scheduledSendTime` on the *freshly assembled* copy before
219
+ // calling `sendMessage()` — `send()` itself is what reads the field and defers relay into Outbox
220
+ // instead of sending immediately (see `mailApi.ts`'s own doc comment on `setMessageScheduledSendTime`).
221
+ async function handleScheduleSend(scheduledSendTimeIso) {
222
+ const toRecipients = parseAddresses(to);
223
+ setSchedulePickerOpen(false);
224
+ if (toRecipients.length === 0) {
225
+ setSendError("At least one recipient is required.");
226
+ return;
227
+ }
228
+ setSending(true);
229
+ setSendError(null);
230
+ try {
231
+ const assembled = await assembleDraft(draft.uid, {
232
+ to: toRecipients,
233
+ cc: parseAddresses(cc),
234
+ bcc: parseAddresses(bcc),
235
+ subject,
236
+ html,
237
+ });
238
+ const withReceipt = requestReceipt ? await setMessageRequestReceipt(assembled, true) : assembled;
239
+ const scheduled = await setMessageScheduledSendTime(withReceipt, scheduledSendTimeIso);
240
+ await sendMessage(scheduled.uid);
241
+ onClose();
242
+ }
243
+ catch (err) {
244
+ setSendError(err instanceof ApiRequestError ? err.message : "Could not schedule this message.");
245
+ }
246
+ finally {
247
+ setSending(false);
248
+ }
249
+ }
250
+ const title = subject.trim() || "New Message";
251
+ const titleId = `compose-title-${id}`;
252
+ if (minimized) {
253
+ 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: _jsxs("div", { className: "h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Restore", onClick: onToggleMinimize, icon: HiOutlineArrowsPointingOut }), _jsx(HeaderButton, { label: "Discard draft", onClick: onClose, icon: HiOutlineXMark })] })] }) }));
254
+ }
255
+ return (_jsxs("div", { ref: windowRef, role: "dialog", "aria-labelledby": titleId, style: !isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined, className: [
256
+ "relative shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
257
+ isMobile
258
+ ? "fixed inset-0 w-full h-full rounded-none border-0"
259
+ : ["border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
260
+ ].join(" "), children: [!isMobile && (_jsxs(_Fragment, { children: [_jsx("div", { onPointerDown: handleResizeStart("top"), "aria-hidden": "true", className: "absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("left"), "aria-hidden": "true", className: "absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("corner"), role: "separator", "aria-label": "Resize", className: "absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30" })] })), _jsxs("div", { className: "h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { id: titleId, className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus }), !isMobile && (_jsx(HeaderButton, { label: expanded ? "Collapse" : "Expand", onClick: toggleExpanded, icon: expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut })), _jsx(HeaderButton, { label: "Close", onClick: onClose, icon: HiOutlineXMark })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError) && (_jsxs("div", { className: "px-3 pt-2", children: [folderError && _jsx(Alert, { children: folderError }), draftError && _jsx(Alert, { children: draftError }), sendError && _jsx(Alert, { children: sendError }), attachError && _jsx(Alert, { children: attachError })] })), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-to-${id}`, className: "text-xs text-text-muted shrink-0", children: "To" }), _jsx("input", { id: `compose-to-${id}`, type: "text", className: FIELD_INPUT, value: to, onChange: (e) => setTo(e.target.value) }), !showCcBcc && (_jsx("button", { type: "button", onClick: () => setShowCcBcc(true), className: "text-xs text-text-muted hover:text-text shrink-0", children: "Cc Bcc" }))] }), showCcBcc && (_jsxs(_Fragment, { children: [_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-cc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Cc" }), _jsx("input", { id: `compose-cc-${id}`, type: "text", className: FIELD_INPUT, value: cc, onChange: (e) => setCc(e.target.value) })] }), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-bcc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Bcc" }), _jsx("input", { id: `compose-bcc-${id}`, type: "text", className: FIELD_INPUT, value: bcc, onChange: (e) => setBcc(e.target.value) })] })] })), _jsx("div", { className: FIELD_ROW, children: _jsx("input", { "aria-label": "Subject", type: "text", placeholder: "Subject", className: FIELD_INPUT, value: subject, onChange: (e) => setSubject(e.target.value) }) }), _jsx("div", { className: "flex-1 min-h-0 p-2", children: contentReady && _jsx(RichTextEditor, { value: html, onChange: setHtml, fill: true, onUploadImage: handleUploadImage }) }), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 px-3 pb-2", children: attachments.map((attachment) => (_jsx("li", { className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted", children: attachment.filename }, attachment.uid))) })), _jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: requestReceipt, onChange: (e) => setRequestReceipt(e.target.checked) }), "Request a read receipt"] }), _jsxs("div", { className: "shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border", children: [_jsxs("div", { className: "flex items-center rounded-pill bg-primary text-white overflow-hidden", children: [_jsx("button", { type: "button", onClick: handleSend, disabled: !draft || sending, className: "py-1.5 pl-5 pr-3 font-semibold text-sm hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: sending ? "Sending…" : "Send" }), _jsx("button", { ref: scheduleButtonRef, type: "button", "aria-label": "Send later", "aria-haspopup": "true", "aria-expanded": schedulePickerOpen, disabled: !draft || sending, onClick: () => setSchedulePickerOpen((o) => !o), className: "py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: _jsx(HiOutlineChevronDown, { size: 14 }) })] }), schedulePickerOpen && (_jsx(ScheduleSendPicker, { anchorRef: scheduleButtonRef, onClose: () => setSchedulePickerOpen(false), onSchedule: handleScheduleSend })), _jsxs("label", { "aria-label": "Attach files", title: "Attach files", className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed", children: [_jsx(HiOutlinePaperClip, { size: 18 }), _jsx("input", { type: "file", multiple: true, disabled: !draft, onChange: handleFilesSelected, className: "sr-only" })] }), _jsx("button", { type: "button", "aria-label": "Discard draft", title: "Discard draft", onClick: onClose, className: "ml-auto w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineTrash, { size: 18 }) })] })] })] }));
261
+ }
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { EMOJI_CATEGORIES } from "@rapidmx/react-shared/mail/compose/emojiData.js";
3
+ import PopoverPortal from "@rapidmx/react-shared/components/overlays/PopoverPortal.js";
4
+ const CATEGORY_LABELS = {
5
+ people: "Smileys & People",
6
+ nature: "Animals & Nature",
7
+ foods: "Food & Drink",
8
+ activity: "Activity",
9
+ places: "Travel & Places",
10
+ objects: "Objects",
11
+ symbols: "Symbols",
12
+ flags: "Flags",
13
+ };
14
+ const WIDTH = 288;
15
+ const HEIGHT = 320;
16
+ /**
17
+ * "Insert emoji" popup — a scrollable grid of every standard unicode emoji, grouped by category (see
18
+ * `emojiData.ts`). Deliberately no search box, unlike `GifPicker`'s — the user's own request only
19
+ * asked for a browsable grid here, and 8 category headers over ~1900 emojis is already a reasonably
20
+ * navigable amount without one. Rendered via `PopoverPortal` — see that component's own doc comment
21
+ * on why (escaping the toolbar's `overflow-hidden` ancestors).
22
+ */
23
+ export default function EmojiPicker({ anchorRef, onSelect, onClose }) {
24
+ return (_jsx(PopoverPortal, { anchorRef: anchorRef, onClose: onClose, width: WIDTH, height: HEIGHT, "aria-label": "Insert emoji", children: _jsx("div", { className: "flex-1 overflow-y-auto p-2", children: EMOJI_CATEGORIES.map((category) => (_jsxs("div", { className: "mb-2", children: [_jsx("h3", { className: "text-[11px] font-bold uppercase tracking-wide text-text-muted px-1 mb-1 sticky top-0 bg-surface", children: CATEGORY_LABELS[category.id] ?? category.id }), _jsx("div", { className: "grid grid-cols-8 gap-0.5", children: category.emojis.map((emoji) => (_jsx("button", { type: "button", title: emoji.name, "aria-label": emoji.name, onClick: () => onSelect(emoji.native), className: "w-8 h-8 flex items-center justify-center text-lg rounded-sm hover:bg-surface-alt", children: emoji.native }, emoji.id))) })] }, category.id))) }) }));
25
+ }
@@ -0,0 +1,36 @@
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, useState } from "react";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { searchGifs } from "@rapidmx/react-shared/mail/compose/giphyApi.js";
9
+ import PopoverPortal from "@rapidmx/react-shared/components/overlays/PopoverPortal.js";
10
+ const SEARCH_DEBOUNCE_MS = 350;
11
+ const WIDTH = 288;
12
+ const HEIGHT = 320;
13
+ /** "Insert GIF" popup — a search box plus a scrollable grid of Giphy thumbnails (trending by default,
14
+ * live-searched as the user types). Selecting one inserts its full-resolution URL into the message
15
+ * body — see `ComposeToolbar.tsx`'s own doc comment on why that's a plain `<img>` reference rather
16
+ * than an uploaded attachment (unlike "Insert image"): a GIF is already hosted at a stable public
17
+ * URL, so there's nothing to upload. Rendered via `PopoverPortal` — see that component's own doc
18
+ * comment on why (escaping the toolbar's `overflow-hidden` ancestors). */
19
+ export default function GifPicker({ anchorRef, onSelect, onClose }) {
20
+ const [query, setQuery] = useState("");
21
+ const [gifs, setGifs] = useState([]);
22
+ const [loading, setLoading] = useState(true);
23
+ const [error, setError] = useState(null);
24
+ useEffect(() => {
25
+ setLoading(true);
26
+ setError(null);
27
+ const handle = setTimeout(() => {
28
+ searchGifs(query)
29
+ .then(setGifs)
30
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load GIFs."))
31
+ .finally(() => setLoading(false));
32
+ }, SEARCH_DEBOUNCE_MS);
33
+ return () => clearTimeout(handle);
34
+ }, [query]);
35
+ return (_jsxs(PopoverPortal, { anchorRef: anchorRef, onClose: onClose, width: WIDTH, height: HEIGHT, "aria-label": "Insert GIF", children: [_jsx("div", { className: "p-2 border-b border-border shrink-0", children: _jsx("input", { type: "text", autoFocus: true, "aria-label": "Search GIFs", placeholder: "Search GIFs", value: query, onChange: (e) => setQuery(e.target.value), className: "w-full text-sm py-1.5 px-2 border border-border rounded-sm bg-surface focus:outline-none focus:border-primary" }) }), _jsx("div", { className: "flex-1 overflow-y-auto p-2", children: error ? (_jsx("p", { className: "text-xs text-danger", children: error })) : loading ? (_jsx("p", { className: "text-xs text-text-muted", children: "Loading\u2026" })) : gifs.length === 0 ? (_jsx("p", { className: "text-xs text-text-muted", children: "No GIFs found." })) : (_jsx("div", { className: "grid grid-cols-2 gap-1.5", children: gifs.map((gif) => (_jsx("button", { type: "button", title: gif.title, onClick: () => onSelect(gif.url), className: "rounded-sm overflow-hidden hover:opacity-80", children: _jsx("img", { src: gif.previewUrl, alt: gif.title, className: "w-full h-20 object-cover" }) }, gif.id))) })) })] }));
36
+ }
@@ -0,0 +1,60 @@
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 { useRef } from "react";
7
+ import { EditorContent, useEditor } from "@tiptap/react";
8
+ import StarterKit from "@tiptap/starter-kit";
9
+ import { TextStyleKit } from "@tiptap/extension-text-style";
10
+ import TextAlign from "@tiptap/extension-text-align";
11
+ import Highlight from "@tiptap/extension-highlight";
12
+ import Image from "@tiptap/extension-image";
13
+ import { TableKit } from "@tiptap/extension-table";
14
+ import Placeholder from "@tiptap/extension-placeholder";
15
+ import ComposeToolbar from "./ComposeToolbar.js";
16
+ /**
17
+ * A WYSIWYG rich-text editor for the Compose window's message body — replaces `MonacoHtmlEditor` (an HTML
18
+ * *source* editor) with a real Outlook-Home-tab-style formatting experience, built on TipTap/ProseMirror.
19
+ * Used by `ComposeWindow` (the floating overlay, not a dedicated page — see `ComposeContext.tsx`'s doc
20
+ * comment).
21
+ *
22
+ * `value` seeds the editor's initial content only — like `MonacoHtmlEditor`, this never re-syncs from a
23
+ * later `value` prop change; TipTap owns its own document once created. Live edits flow out as HTML (via
24
+ * `editor.getHTML()`) through `onChange`.
25
+ *
26
+ * `immediatelyRender: false` is required for this framework's SSR: the page embedding `ComposeWindow` is
27
+ * rendered server-side under plain Node before hydration (see `ReactRoute`), and TipTap's default
28
+ * (`immediatelyRender: true`) tries to mount its ProseMirror view during that very first render, which
29
+ * both can't work without a real DOM and produces a hydration mismatch. With it `false`, `useEditor`
30
+ * returns `null` until the editor actually mounts client-side after hydration — `ComposeToolbar`/
31
+ * `EditorContent` both already handle a `null` editor by rendering their disabled/empty state, so this
32
+ * needs no extra loading-placeholder logic here. Unlike `MonacoHtmlEditor`, no dynamic `import()` is
33
+ * needed to defer loading TipTap's modules off the SSR path — `immediatelyRender: false` alone is
34
+ * sufficient here since TipTap (unlike Monaco) never touches the DOM at module-evaluation time, only when
35
+ * an editor view actually mounts.
36
+ */
37
+ export default function RichTextEditor({ value, onChange, height = "360px", fill = false, onUploadImage }) {
38
+ const onChangeRef = useRef(onChange);
39
+ onChangeRef.current = onChange;
40
+ const editor = useEditor({
41
+ extensions: [
42
+ StarterKit.configure({ link: { openOnClick: false } }),
43
+ TextStyleKit.configure({ backgroundColor: false, lineHeight: false }),
44
+ TextAlign.configure({ types: ["heading", "paragraph"] }),
45
+ Highlight,
46
+ Image,
47
+ TableKit.configure({ table: { resizable: true } }),
48
+ Placeholder.configure({ placeholder: "Write your message…" }),
49
+ ],
50
+ content: value,
51
+ immediatelyRender: false,
52
+ onUpdate: ({ editor: updated }) => onChangeRef.current(updated.getHTML()),
53
+ });
54
+ 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: [
55
+ "overflow-y-auto px-3 py-2 text-sm [&_.tiptap]:outline-none [&_.tiptap]:h-full",
56
+ fill ? "flex-1 min-h-0" : "",
57
+ ]
58
+ .filter(Boolean)
59
+ .join(" ") })] }));
60
+ }
@@ -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 { useState } from "react";
7
+ import PopoverPortal from "@rapidmx/react-shared/components/overlays/PopoverPortal.js";
8
+ /**
9
+ * The "Send later" popover next to Compose's Send button — same `PopoverPortal` shell
10
+ * `EmojiPicker`/`GifPicker`/`ResourcePicker` already use, just holding a single `datetime-local` input
11
+ * instead of a grid/list. Validates the chosen time is genuinely in the future before calling
12
+ * `onSchedule` — `@rapidmx/restapi`'s own `send()` only defers when `scheduledSendTime` is strictly
13
+ * later than "now", so a past/immediate pick here would silently send right away instead of scheduling,
14
+ * which would surprise a reader expecting the picker's own choice to be honored.
15
+ */
16
+ export default function ScheduleSendPicker({ anchorRef, onClose, onSchedule }) {
17
+ const [value, setValue] = useState("");
18
+ const [error, setError] = useState(null);
19
+ function handleSubmit(e) {
20
+ e.preventDefault();
21
+ if (!value) {
22
+ setError("Pick a date and time.");
23
+ return;
24
+ }
25
+ const iso = new Date(value).toISOString();
26
+ if (new Date(iso).getTime() <= Date.now()) {
27
+ setError("Pick a time in the future.");
28
+ return;
29
+ }
30
+ onSchedule(iso);
31
+ }
32
+ return (_jsx(PopoverPortal, { anchorRef: anchorRef, onClose: onClose, width: 260, height: 150, "aria-label": "Schedule send", children: _jsxs("form", { onSubmit: handleSubmit, className: "p-3 flex flex-col gap-2", children: [_jsx("label", { className: "text-xs font-semibold", htmlFor: "schedule-send-time", children: "Send at" }), _jsx("input", { id: "schedule-send-time", type: "datetime-local", className: "text-sm py-1.5 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary", value: value, onChange: (e) => setValue(e.target.value) }), error && _jsx("p", { className: "text-xs text-danger", children: error }), _jsx("button", { type: "submit", className: "text-sm font-semibold py-1.5 px-3 rounded-pill bg-primary text-white hover:bg-primary-dark", children: "Send later" })] }) }));
33
+ }