@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.
- package/LICENSE +382 -0
- package/apps/admin/_404.tsx +9 -0
- package/apps/admin/_500.tsx +9 -0
- package/apps/admin/_layout.tsx +41 -0
- package/apps/admin/audit-log/index.tsx +187 -0
- package/apps/admin/branding/index.tsx +404 -0
- package/apps/admin/distribution-lists/[uid].tsx +70 -0
- package/apps/admin/distribution-lists/index.tsx +115 -0
- package/apps/admin/distribution-lists/new/index.tsx +114 -0
- package/apps/admin/domains/[uid].tsx +259 -0
- package/apps/admin/domains/index.tsx +125 -0
- package/apps/admin/domains/new/index.tsx +84 -0
- package/apps/admin/index.tsx +82 -0
- package/apps/admin/ingest-queue/index.tsx +116 -0
- package/apps/admin/mailboxes/[uid].tsx +126 -0
- package/apps/admin/mailboxes/new/index.tsx +291 -0
- package/apps/admin/quarantine/index.tsx +171 -0
- package/apps/admin/transport-rules/[uid].tsx +143 -0
- package/apps/admin/transport-rules/_transportRuleConfig.tsx +75 -0
- package/apps/admin/transport-rules/index.tsx +117 -0
- package/apps/admin/transport-rules/new/index.tsx +105 -0
- package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +117 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +186 -0
- package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +73 -0
- package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +154 -0
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +124 -0
- package/apps/shared/components/booking/AvailabilityEditor.tsx +118 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +119 -0
- package/apps/shared/components/calendar/EventModal.tsx +513 -0
- package/apps/shared/components/calendar/MonthView.tsx +139 -0
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +180 -0
- package/apps/shared/components/calendar/ResourcePicker.tsx +80 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +137 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +198 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +211 -0
- package/apps/shared/components/contacts/ContactDetailPane.tsx +99 -0
- package/apps/shared/components/contacts/ContactForm.tsx +314 -0
- package/apps/shared/components/contacts/ContactsSidebar.tsx +242 -0
- package/apps/shared/components/contacts/ContactsToolbar.tsx +115 -0
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +197 -0
- package/apps/shared/components/layout/AppShell.tsx +165 -0
- package/apps/shared/components/layout/BrandingChrome.tsx +25 -0
- package/apps/shared/components/layout/MailboxProvisioning.tsx +110 -0
- package/apps/shared/components/layout/UserMenu.tsx +150 -0
- package/apps/shared/components/mail/ConversationList.tsx +61 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +174 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +390 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +114 -0
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +418 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +518 -0
- package/apps/shared/components/mail/compose/EmojiPicker.tsx +64 -0
- package/apps/shared/components/mail/compose/GifPicker.tsx +82 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +83 -0
- package/apps/shared/components/mail/compose/ScheduleSendPicker.tsx +64 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +287 -0
- package/apps/shared/components/rules/RuleBuilder.tsx +276 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +223 -0
- package/apps/shared/components/tasks/TasksSidebar.tsx +201 -0
- package/apps/shared/components/tasks/TasksToolbar.tsx +68 -0
- package/apps/shared/components/tasks/layout/TasksShell.tsx +199 -0
- package/apps/shared/styles/app.css +265 -0
- package/apps/www/_404.tsx +9 -0
- package/apps/www/_500.tsx +9 -0
- package/apps/www/_layout.tsx +45 -0
- package/apps/www/calendar/index.tsx +407 -0
- package/apps/www/contacts/[uid].tsx +88 -0
- package/apps/www/contacts/index.tsx +436 -0
- package/apps/www/index.tsx +375 -0
- package/apps/www/messages/[uid].tsx +70 -0
- package/apps/www/settings/auto-reply/index.tsx +129 -0
- package/apps/www/settings/booking-types/[uid].tsx +300 -0
- package/apps/www/settings/booking-types/index.tsx +98 -0
- package/apps/www/settings/booking-types/new/index.tsx +212 -0
- package/apps/www/settings/filters/[uid].tsx +155 -0
- package/apps/www/settings/filters/_mailFilterRuleConfig.tsx +102 -0
- package/apps/www/settings/filters/index.tsx +99 -0
- package/apps/www/settings/filters/new/index.tsx +126 -0
- package/apps/www/settings/focused-inbox/index.tsx +148 -0
- package/apps/www/settings/read-receipts/index.tsx +125 -0
- package/apps/www/settings/signatures/[uid].tsx +167 -0
- package/apps/www/settings/signatures/index.tsx +85 -0
- package/apps/www/settings/signatures/new/index.tsx +129 -0
- package/apps/www/settings/signatures/signatureDefaults.ts +36 -0
- package/apps/www/tasks/index.tsx +530 -0
- package/dist/apps/admin/_404.js +4 -0
- package/dist/apps/admin/_500.js +4 -0
- package/dist/apps/admin/_layout.js +10 -0
- package/dist/apps/admin/audit-log/index.js +66 -0
- package/dist/apps/admin/branding/index.js +126 -0
- package/dist/apps/admin/distribution-lists/[uid].js +34 -0
- package/dist/apps/admin/distribution-lists/index.js +31 -0
- package/dist/apps/admin/distribution-lists/new/index.js +51 -0
- package/dist/apps/admin/domains/[uid].js +102 -0
- package/dist/apps/admin/domains/index.js +31 -0
- package/dist/apps/admin/domains/new/index.js +41 -0
- package/dist/apps/admin/index.js +32 -0
- package/dist/apps/admin/ingest-queue/index.js +48 -0
- package/dist/apps/admin/mailboxes/[uid].js +59 -0
- package/dist/apps/admin/mailboxes/new/index.js +93 -0
- package/dist/apps/admin/quarantine/index.js +66 -0
- package/dist/apps/admin/transport-rules/[uid].js +87 -0
- package/dist/apps/admin/transport-rules/_transportRuleConfig.js +37 -0
- package/dist/apps/admin/transport-rules/index.js +32 -0
- package/dist/apps/admin/transport-rules/new/index.js +50 -0
- package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +64 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +94 -0
- package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +16 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +59 -0
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +67 -0
- package/dist/apps/shared/components/booking/AvailabilityEditor.js +45 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +44 -0
- package/dist/apps/shared/components/calendar/EventModal.js +167 -0
- package/dist/apps/shared/components/calendar/MonthView.js +38 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +55 -0
- package/dist/apps/shared/components/calendar/ResourcePicker.js +36 -0
- package/dist/apps/shared/components/calendar/SplitDayView.js +44 -0
- package/dist/apps/shared/components/calendar/TimeGridView.js +58 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +96 -0
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -0
- package/dist/apps/shared/components/contacts/ContactForm.js +85 -0
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +105 -0
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +32 -0
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +93 -0
- package/dist/apps/shared/components/layout/AppShell.js +68 -0
- package/dist/apps/shared/components/layout/BrandingChrome.js +18 -0
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +75 -0
- package/dist/apps/shared/components/layout/UserMenu.js +59 -0
- package/dist/apps/shared/components/mail/ConversationList.js +17 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +112 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +153 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +55 -0
- package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +88 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +261 -0
- package/dist/apps/shared/components/mail/compose/EmojiPicker.js +25 -0
- package/dist/apps/shared/components/mail/compose/GifPicker.js +36 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +60 -0
- package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.js +33 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +139 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +78 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +100 -0
- package/dist/apps/shared/components/tasks/TasksSidebar.js +81 -0
- package/dist/apps/shared/components/tasks/TasksToolbar.js +15 -0
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +92 -0
- package/dist/apps/shared/styles/app.css +265 -0
- package/dist/apps/www/_404.js +4 -0
- package/dist/apps/www/_500.js +4 -0
- package/dist/apps/www/_layout.js +8 -0
- package/dist/apps/www/calendar/index.js +228 -0
- package/dist/apps/www/contacts/[uid].js +62 -0
- package/dist/apps/www/contacts/index.js +262 -0
- package/dist/apps/www/index.js +228 -0
- package/dist/apps/www/messages/[uid].js +47 -0
- package/dist/apps/www/settings/auto-reply/index.js +54 -0
- package/dist/apps/www/settings/booking-types/[uid].js +129 -0
- package/dist/apps/www/settings/booking-types/index.js +29 -0
- package/dist/apps/www/settings/booking-types/new/index.js +86 -0
- package/dist/apps/www/settings/filters/[uid].js +94 -0
- package/dist/apps/www/settings/filters/_mailFilterRuleConfig.js +65 -0
- package/dist/apps/www/settings/filters/index.js +30 -0
- package/dist/apps/www/settings/filters/new/index.js +61 -0
- package/dist/apps/www/settings/focused-inbox/index.js +69 -0
- package/dist/apps/www/settings/read-receipts/index.js +51 -0
- package/dist/apps/www/settings/signatures/[uid].js +96 -0
- package/dist/apps/www/settings/signatures/index.js +34 -0
- package/dist/apps/www/settings/signatures/new/index.js +63 -0
- package/dist/apps/www/settings/signatures/signatureDefaults.js +30 -0
- package/dist/apps/www/tasks/index.js +280 -0
- 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
|
+
}
|