ihasmail 2.0: rebuild as Stalwart-first JMAP webmail

Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a
React 19/Vite SPA. Mail (conversation view, search operators, labels,
sanitised HTML, privacy image proxy, invites, undo send, templates),
calendar (month/week/day/agenda, invites, free/busy, categories,
context menus), contacts (JSContact, groups, vCard), files, Sieve filter
builder (incl. filter-from-message with retroactive apply), vacation,
identities with default + Reply-To, PWA/mobile layout, push via SSE,
in-memory mock Stalwart for dev, Docker + CI.
This commit is contained in:
2026-08-23 01:07:13 -07:00
parent fe17e1d507
commit 645b8b510f
162 changed files with 20398 additions and 1072 deletions
+232
View File
@@ -0,0 +1,232 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AlertTriangle, ChevronDown, FileText, Maximize2, Minimize2, Minus, MoreVertical, Paperclip, Send, Trash2, X, Type, Clock, CheckCheck, ChevronsDown } from "lucide-react";
import { useCompose, type Draft } from "@/store/compose";
import { useMail } from "@/store/mail";
import { useSettings } from "@/store/settings";
import { RecipientInput } from "./RecipientInput";
import { RichEditor, type RichEditorHandle } from "./RichEditor";
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
import { confirmDialog, promptDialog } from "@/ui/dialog";
import { formatSize, formatRelative } from "@/lib/format";
import { htmlToText, textToHtml } from "@/lib/text";
import { isValidEmail } from "@/lib/address";
import { attachmentIcon } from "../mail/MessageView";
import { keyboard } from "@/lib/keyboard";
import { useIsMobile } from "@/ui/misc";
import { toast } from "@/ui/toast";
export function Composer({ draft }: { draft: Draft }) {
const update = useCompose((s) => s.update);
const close = useCompose((s) => s.close);
const send = useCompose((s) => s.send);
const saveDraft = useCompose((s) => s.saveDraft);
const addFiles = useCompose((s) => s.addFiles);
const removeAttachment = useCompose((s) => s.removeAttachment);
const setIdentity = useCompose((s) => s.setIdentity);
const insertTemplate = useCompose((s) => s.insertTemplate);
const focus = useCompose((s) => s.focus);
const identities = useMail((s) => s.identities);
const settings = useSettings((s) => s.settings);
const updateSettings = useSettings((s) => s.update);
const isMobile = useIsMobile();
const editorRef = useRef<RichEditorHandle>(null);
const fileRef = useRef<HTMLInputElement>(null);
const [dropping, setDropping] = useState(false);
const moreMenu = useMenu();
const sendMenu = useMenu();
const templateMenu = useMenu();
const [showToolbar, setShowToolbar] = useState(true);
const d = draft;
const key = d.key;
const patch = useCallback((p: Partial<Draft>) => update(key, p), [update, key]);
const onHtml = useCallback((html: string) => update(key, { html }), [update, key]);
// Esc closes (saves draft); Ctrl+Enter sends
useEffect(() => {
if (d.minimized) return;
return keyboard.pushScope("composer", [
{ keys: "mod+enter", description: "Send message", group: "Compose", handler: () => void doSend(), allowInInput: true },
{ keys: "esc", description: "Close composer (saves draft)", group: "Compose", handler: () => { if (document.activeElement?.closest(".composer")) { void close(key); return true; } return false; }, allowInInput: true },
{ keys: "mod+s", description: "Save draft", group: "Compose", handler: () => { void saveDraft(key); }, allowInInput: true },
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, d.minimized]);
const bodyText = useMemo(() => (d.format === "html" ? htmlToText(d.html.replace(/<div class="ihm-quote">[\s\S]*$/, "")) : d.text), [d.html, d.text, d.format]);
const doSend = async () => {
const all = [...d.to, ...d.cc, ...d.bcc];
if (!all.length) {
toast.error("Please add at least one recipient");
return;
}
const bad = all.filter((a) => !isValidEmail(a.email));
if (bad.length) {
toast.error(`Invalid address: ${bad[0]!.email}`);
return;
}
if (d.attachments.some((a) => a.error)) {
toast.error("Remove attachments that failed to upload first");
return;
}
if (d.attachments.some((a) => !a.blobId)) {
toast.error("Attachments are still uploading");
return;
}
if (!d.subject.trim()) {
const ok = await confirmDialog({ title: "Send without a subject?", confirmLabel: "Send anyway" });
if (!ok) return;
}
if (settings.attachmentReminder && !d.attachments.length && /\b(attach(ed|ment|ing)?|enclosed|anbei|ci-joint|adjunto)\b/i.test(bodyText) ) {
const ok = await confirmDialog({ title: "Did you forget the attachment?", message: "Your message mentions an attachment, but nothing is attached.", confirmLabel: "Send anyway" });
if (!ok) return;
}
await send(key);
};
const toggleFormat = () => {
if (d.format === "html") {
patch({ format: "text", text: htmlToText(d.html) });
} else {
patch({ format: "html", html: textToHtml(d.text, { linkify: false, quoteColors: false }).replace(/\n/g, "<br>") });
}
};
const onDrop = (e: React.DragEvent) => {
e.preventDefault();
setDropping(false);
const files = Array.from(e.dataTransfer.files);
if (files.length) addFiles(key, files);
};
const ident = identities.find((i) => i.id === d.identityId) ?? identities[0];
const title = d.subject || (d.replyMode ? (d.replyMode === "forward" ? "Forward" : "Reply") : "New message");
const status = d.sending ? "Sending…" : d.saving ? "Saving…" : d.error ? "Error" : d.savedAt ? `Saved ${formatRelative(new Date(d.savedAt).toISOString())}` : d.dirty ? "Unsaved" : "";
const totalSize = d.attachments.reduce((n, a) => n + a.size, 0);
if (d.minimized) {
return (
<div className="composer minimized" onClick={() => focus(key)}>
<div className="composer-head">
<span className="title">{title}</span>
<button className="icon-btn sm" aria-label="Restore" onClick={(e) => { e.stopPropagation(); focus(key); }}><Maximize2 size={16} /></button>
<button className="icon-btn sm" aria-label="Close" onClick={(e) => { e.stopPropagation(); void close(key); }}><X size={16} /></button>
</div>
</div>
);
}
return (
<div className={`composer ${d.maximized ? "maximized" : ""} ${dropping ? "dropping" : ""}`} onDragOver={(e) => { if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } }} onDragLeave={() => setDropping(false)} onDrop={onDrop} role="dialog" aria-label="Compose message">
<div className="composer-head" onDoubleClick={() => patch({ maximized: !d.maximized })}>
<span className="title">{title}</span>
<span className="status">{status}</span>
{!isMobile && <button className="icon-btn sm" aria-label="Minimize" title="Minimize" onClick={() => patch({ minimized: true })}><Minus size={16} /></button>}
{!isMobile && <button className="icon-btn sm" aria-label={d.maximized ? "Restore" : "Maximize"} title={d.maximized ? "Restore" : "Full screen"} onClick={() => patch({ maximized: !d.maximized })}>{d.maximized ? <Minimize2 size={16} /> : <Maximize2 size={16} />}</button>}
<button className="icon-btn sm" aria-label="Close" title="Save & close (Esc)" onClick={() => void close(key)}><X size={18} /></button>
</div>
<div className="composer-body">
<div className="composer-fields">
{identities.length > 1 && (
<div className="composer-field">
<label>From</label>
<select className="from-select" value={ident?.id ?? ""} onChange={(e) => setIdentity(key, e.target.value)}>
{identities.map((i) => <option key={i.id} value={i.id}>{i.name ? `${i.name} <${i.email}>` : i.email}</option>)}
</select>
</div>
)}
<div className="composer-field">
<label htmlFor={`${key}-to`}>To</label>
<RecipientInput id={`${key}-to`} value={d.to} onChange={(to) => patch({ to })} placeholder="Recipients" autoFocus={!d.to.length} />
<span className="field-extra">
{!d.showCc && <button type="button" onClick={() => patch({ showCc: true })}>Cc</button>}
{!d.showBcc && <button type="button" onClick={() => patch({ showBcc: true })}>Bcc</button>}
{!d.showReplyTo && <button type="button" onClick={() => patch({ showReplyTo: true })} title="Set a Reply-To address">Reply-To</button>}
</span>
</div>
{d.showReplyTo && (
<div className="composer-field">
<label htmlFor={`${key}-rt`} title="Replies will go to this address instead of the From address">Reply-To</label>
<RecipientInput id={`${key}-rt`} value={d.replyTo} onChange={(replyTo) => patch({ replyTo })} placeholder="Replies go to…" />
</div>
)}
{d.showCc && (
<div className="composer-field">
<label htmlFor={`${key}-cc`}>Cc</label>
<RecipientInput id={`${key}-cc`} value={d.cc} onChange={(cc) => patch({ cc })} />
</div>
)}
{d.showBcc && (
<div className="composer-field">
<label htmlFor={`${key}-bcc`}>Bcc</label>
<RecipientInput id={`${key}-bcc`} value={d.bcc} onChange={(bcc) => patch({ bcc })} />
</div>
)}
<div className="composer-field">
<label htmlFor={`${key}-subj`} className="sr-only">Subject</label>
<input id={`${key}-subj`} className="plain" placeholder="Subject" value={d.subject} onChange={(e) => patch({ subject: e.target.value })} autoFocus={d.to.length > 0 && !d.subject} />
{d.priority !== "normal" && <span className="tag" style={{ background: d.priority === "high" ? "var(--danger)" : "var(--fg-faint)" }}>{d.priority === "high" ? "High priority" : "Low priority"}</span>}
{d.requestReceipt && <span className="tag" style={{ background: "var(--accent)" }} title="Read receipt requested"><CheckCheck size={12} /></span>}
</div>
</div>
{d.format === "html" ? (
<RichEditor ref={editorRef} html={d.html} onChange={onHtml} placeholder="Write your message…" spellcheck={settings.spellcheck} onFiles={(files) => addFiles(key, files)} showToolbar={showToolbar} autoFocus={d.to.length > 0 && Boolean(d.subject)} />
) : (
<textarea className="editor-textarea" value={d.text} onChange={(e) => patch({ text: e.target.value })} placeholder="Write your message…" spellCheck={settings.spellcheck} />
)}
{d.attachments.some((a) => !a.inline) && (
<div className="composer-attachments">
{d.attachments.filter((a) => !a.inline).map((a) => (
<div key={a.id} className={`attachment ${a.error ? "error" : ""}`} title={a.error ?? a.name}>
<span className="att-icon">{attachmentIcon(a.type, a.name)}</span>
<span className="att-text">
<span className="att-name">{a.name}</span>
<span className="att-size">{a.error ? <span style={{ color: "var(--danger)" }}>{a.error}</span> : a.blobId ? formatSize(a.size) : `${a.progress}%`}{a.inline ? " · inline" : ""}</span>
</span>
<button className="icon-btn xs" aria-label="Remove attachment" onClick={() => removeAttachment(key, a.id)}><X size={14} /></button>
{!a.blobId && !a.error && <span className="att-progress" style={{ width: `${a.progress}%` }} />}
</div>
))}
</div>
)}
<div className="composer-foot">
<span className="send-group">
<button className="btn btn-primary" onClick={() => void doSend()} disabled={d.sending} title="Send (Ctrl+Enter)"><Send size={16} /> Send</button>
<button className="btn btn-primary" onClick={sendMenu.open} aria-label="Send options"><ChevronDown size={16} /></button>
</span>
<Popover anchor={sendMenu.anchor} onClose={sendMenu.close} side="top" width={240}>
<MenuItem icon={<Send size={16} />} label="Send" kbd="Ctrl+↵" onClick={() => void doSend()} />
<MenuItem icon={<Clock size={16} />} label={`Undo window: ${settings.undoSendSeconds}s`} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} />
</Popover>
<span className="more-actions">
<button className="icon-btn" title="Attach files" onClick={() => fileRef.current?.click()}><Paperclip size={18} /></button>
<input ref={fileRef} type="file" multiple hidden onChange={(e) => { const files = Array.from(e.target.files ?? []); if (files.length) addFiles(key, files); e.target.value = ""; }} />
{d.format === "html" && <button className={`icon-btn ${showToolbar ? "active" : ""}`} title="Formatting options" onClick={() => setShowToolbar((v) => !v)}><Type size={18} /></button>}
{settings.templates.length > 0 && <button className="icon-btn" title="Insert template" onClick={templateMenu.open}><FileText size={18} /></button>}
<Popover anchor={templateMenu.anchor} onClose={templateMenu.close} side="top" width={260}>
<MenuTitle>Templates</MenuTitle>
{settings.templates.map((t) => <MenuItem key={t.id} label={t.name} onClick={() => insertTemplate(key, t.html, t.subject)} />)}
</Popover>
<button className="icon-btn" onClick={moreMenu.open} aria-label="More options"><MoreVertical size={18} /></button>
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} side="top" width={260}>
<MenuItem icon={<Type size={16} />} label={d.format === "html" ? "Switch to plain text" : "Switch to rich text"} onClick={toggleFormat} />
<MenuItem icon={<CheckCheck size={16} />} label="Request read receipt" checked={d.requestReceipt} onClick={() => patch({ requestReceipt: !d.requestReceipt })} />
<MenuSep />
<MenuTitle>Priority</MenuTitle>
<MenuItem label="High" checked={d.priority === "high"} onClick={() => patch({ priority: "high" })} />
<MenuItem label="Normal" checked={d.priority === "normal"} onClick={() => patch({ priority: "normal" })} />
<MenuItem label="Low" checked={d.priority === "low"} onClick={() => patch({ priority: "low" })} />
<MenuSep />
<MenuItem icon={<ChevronsDown size={16} />} label="Save as template" onClick={async () => { const name = await promptDialog({ title: "Save as template", defaultValue: d.subject || "Template", placeholder: "Template name" }); if (name) updateSettings({ templates: [...useSettings.getState().settings.templates, { id: `t${Date.now()}`, name, subject: d.subject, html: d.format === "html" ? d.html : textToHtml(d.text) }] }); }} />
<MenuItem icon={<FileText size={16} />} label="Save draft now" onClick={() => void saveDraft(key)} />
</Popover>
</span>
<span className="spacer" />
{totalSize > 20 * 1024 * 1024 && <span className="hint row gap-4" title="Large attachments may be rejected by some servers"><AlertTriangle size={14} /> {formatSize(totalSize)}</span>}
<button className="icon-btn danger" title="Discard draft" aria-label="Discard draft" onClick={async () => { if (!d.dirty && !d.draftId) { void close(key, { discard: true }); return; } if (await confirmDialog({ title: "Discard this draft?", confirmLabel: "Discard", danger: true })) void close(key, { discard: true }); }}><Trash2 size={18} /></button>
</div>
</div>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { useCompose } from "@/store/compose";
import { Composer } from "./Composer";
import { useIsMobile } from "@/ui/misc";
export function ComposerDock() {
const drafts = useCompose((s) => s.drafts);
const activeKey = useCompose((s) => s.activeKey);
const isMobile = useIsMobile();
if (!drafts.length) return null;
// On mobile only the active composer is shown (full screen); others are minimized bars.
const visible = isMobile ? drafts.filter((d) => d.key === activeKey || d.minimized) : drafts;
return (
<div className="composer-dock">
{visible.map((d) => (
<Composer key={d.key} draft={isMobile && d.key !== activeKey ? { ...d, minimized: true } : d} />
))}
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
import { useEffect, useRef, useState, type KeyboardEvent, type ClipboardEvent } from "react";
import { X } from "lucide-react";
import type { EmailAddress } from "@/jmap/types";
import { isValidEmail, parseAddressList, displayName } from "@/lib/address";
import { useContacts, type Suggestion } from "@/store/contacts";
import { Avatar } from "@/ui/misc";
interface Props {
value: EmailAddress[];
onChange: (v: EmailAddress[]) => void;
placeholder?: string;
autoFocus?: boolean;
id?: string;
}
export function RecipientInput({ value, onChange, placeholder, autoFocus, id }: Props) {
const [text, setText] = useState("");
const [sugg, setSugg] = useState<Suggestion[]>([]);
const [active, setActive] = useState(0);
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const suggest = useContacts((s) => s.suggest);
const reqId = useRef(0);
useEffect(() => {
const q = text.trim();
if (!q) {
setSugg([]);
setOpen(false);
return;
}
const id = ++reqId.current;
const t = window.setTimeout(() => {
void suggest(q).then((list) => {
if (id !== reqId.current) return;
const existing = new Set(value.map((v) => v.email.toLowerCase()));
const filtered = list.filter((s) => !existing.has(s.email.toLowerCase()));
setSugg(filtered);
setActive(0);
setOpen(filtered.length > 0);
});
}, 120);
return () => window.clearTimeout(t);
}, [text, suggest, value]);
const commit = (raw?: string) => {
const s = (raw ?? text).trim().replace(/[,;]+$/, "");
if (!s) return;
const parsed = parseAddressList(s);
if (!parsed.length) return;
onChange([...value, ...parsed.filter((p) => !value.some((v) => v.email.toLowerCase() === p.email.toLowerCase()))]);
setText("");
setOpen(false);
};
const pick = (s: Suggestion) => {
onChange([...value, { name: s.name, email: s.email }]);
setText("");
setOpen(false);
inputRef.current?.focus();
};
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
if (open && sugg.length) {
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((a) => (a + 1) % sugg.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => (a - 1 + sugg.length) % sugg.length);
return;
}
if (e.key === "Enter" || e.key === "Tab") {
if (sugg[active]) {
e.preventDefault();
pick(sugg[active]!);
return;
}
}
if (e.key === "Escape") {
setOpen(false);
e.stopPropagation();
return;
}
}
if (e.key === "Enter" || e.key === "," || e.key === ";") {
if (text.trim()) {
e.preventDefault();
commit();
} else if (e.key === "Enter") e.preventDefault();
} else if (e.key === "Tab" && text.trim()) {
commit();
} else if (e.key === "Backspace" && !text && value.length) {
const last = value[value.length - 1]!;
onChange(value.slice(0, -1));
setText(last.name ? `${last.name} <${last.email}>` : last.email);
}
};
const onPaste = (e: ClipboardEvent<HTMLInputElement>) => {
const t = e.clipboardData.getData("text");
if (t && /[,;\n]|<.+@.+>/.test(t)) {
e.preventDefault();
commit(text + t);
}
};
return (
<div className="recipients" onClick={() => inputRef.current?.focus()}>
{value.map((a, i) => (
<span key={`${a.email}-${i}`} className={`chip ${isValidEmail(a.email) ? "" : "invalid"}`} title={a.email}>
<span className="truncate" style={{ maxWidth: 220 }}>{a.name ? displayName(a) : a.email}</span>
<button type="button" className="chip-x" aria-label={`Remove ${a.email}`} onClick={(e) => { e.stopPropagation(); onChange(value.filter((_, j) => j !== i)); }}>
<X size={12} />
</button>
</span>
))}
<input
id={id}
ref={inputRef}
value={text}
placeholder={value.length ? "" : placeholder}
autoFocus={autoFocus}
onChange={(e) => setText(e.target.value)}
onKeyDown={onKey}
onPaste={onPaste}
onBlur={() => { window.setTimeout(() => { setOpen(false); if (text.trim()) commit(); }, 150); }}
onFocus={() => sugg.length && setOpen(true)}
autoComplete="off"
autoCapitalize="off"
spellCheck={false}
aria-autocomplete="list"
aria-expanded={open}
/>
{open && (
<div className="suggest-list" role="listbox">
{sugg.map((s, i) => (
<div key={s.email} className={`suggest-item ${i === active ? "active" : ""}`} role="option" aria-selected={i === active} onMouseDown={(e) => { e.preventDefault(); pick(s); }} onMouseEnter={() => setActive(i)}>
<Avatar who={s} size="sm" />
<div className="col" style={{ minWidth: 0 }}>
<span className="s-name truncate">{s.name ?? s.email}</span>
{s.name && <span className="s-email truncate">{s.email}</span>}
</div>
<span className="s-src">{s.source === "gal" ? "Directory" : s.source === "recent" ? "Recent" : ""}</span>
</div>
))}
</div>
)}
</div>
);
}
+286
View File
@@ -0,0 +1,286 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, type ClipboardEvent, type ReactNode } from "react";
import { AlignCenter, AlignLeft, AlignRight, Bold, Code, Eraser, Image as ImageIcon, Indent, Italic, Link as LinkIcon, List, ListOrdered, Outdent, Quote, Redo, Smile, Strikethrough, Underline, Undo, Palette, Highlighter, Type } from "lucide-react";
import { sanitizeEditorHtml } from "@/lib/html";
import { Popover, useMenu } from "@/ui/popover";
export interface RichEditorHandle {
focus(): void;
insertHtml(html: string): void;
insertText(text: string): void;
getHtml(): string;
}
interface Props {
html: string;
onChange: (html: string) => void;
placeholder?: string;
spellcheck?: boolean;
onFiles?: (files: File[]) => void;
toolbarExtra?: ReactNode;
showToolbar: boolean;
autoFocus?: boolean;
/** If provided, inserted images are uploaded and referenced by URL instead of embedded as data: URLs. */
imageUpload?: (file: File) => Promise<string>;
}
const EMOJI = "😀 😃 😄 😁 😆 😅 😂 🤣 🙂 😉 😊 😇 🥰 😍 😘 😋 😜 🤪 🤗 🤔 🤫 🤐 😐 😑 😶 😏 😒 🙄 😬 😌 😔 😪 😴 😷 🤒 🤕 🤢 🤮 🥵 🥶 🥴 😵 🤯 🤠 🥳 😎 🤓 🧐 😕 😟 🙁 😮 😯 😲 😳 🥺 😦 😧 😨 😰 😥 😢 😭 😱 😖 😣 😞 😓 😩 😫 🥱 😤 😡 😠 🤬 👍 👎 👌 ✌️ 🤞 🤟 🤘 🤙 👈 👉 👆 👇 ☝️ 👋 🤚 🖐️ ✋ 🖖 👏 🙌 👐 🤲 🤝 🙏 💪 ❤️ 🧡 💛 💚 💙 💜 🖤 🤍 💔 ❣️ 💕 💯 💥 🔥 ✨ 🎉 🎊 🎈 🎁 🏆 ⭐ 🌟 ☀️ 🌙 ⚡ ☕ 🍕 🍺 🚀 ✈️ 🏠 💼 📅 📎 📌 ✅ ❌ ⚠️ ❓ ❗ 💡 🔔 📧 🙈 🙉 🙊 🐱 🐶 🦊 🐼".split(" ");
const COLORS = ["#000000", "#434343", "#666666", "#999999", "#b7b7b7", "#cccccc", "#d9d9d9", "#ffffff", "#980000", "#ff0000", "#ff9900", "#ffff00", "#00ff00", "#00ffff", "#4a86e8", "#0000ff", "#9900ff", "#ff00ff", "#e6b8af", "#f4cccc", "#fce5cd", "#fff2cc", "#d9ead3", "#d0e0e3", "#c9daf8", "#cfe2f3", "#d9d2e9", "#ead1dc", "#cc4125", "#e06666", "#f6b26b", "#ffd966", "#93c47d", "#76a5af", "#6d9eeb", "#6fa8dc", "#8e7cc3", "#c27ba0", "#a61c00", "#cc0000", "#e69138", "#f1c232", "#6aa84f", "#45818e", "#3c78d8", "#3d85c6", "#674ea7", "#a64d79"];
export const RichEditor = forwardRef<RichEditorHandle, Props>(function RichEditor({ html, onChange, placeholder, spellcheck = true, onFiles, toolbarExtra, showToolbar, autoFocus, imageUpload }, ref) {
const elRef = useRef<HTMLDivElement>(null);
const lastEmitted = useRef<string>("");
const [empty, setEmpty] = useState(!html);
const emojiMenu = useMenu();
const colorMenu = useMenu();
const hiliteMenu = useMenu();
const linkMenu = useMenu();
const [linkUrl, setLinkUrl] = useState("");
const savedRange = useRef<Range | null>(null);
// Sync external html → DOM (only when it differs from what we emitted)
useEffect(() => {
const el = elRef.current;
if (!el) return;
if (html !== lastEmitted.current) {
el.innerHTML = html;
lastEmitted.current = html;
setEmpty(!el.textContent?.trim() && !el.querySelector("img"));
}
}, [html]);
useEffect(() => {
if (autoFocus) {
const el = elRef.current;
if (!el) return;
el.focus();
// caret at start
const sel = window.getSelection();
const range = document.createRange();
range.setStart(el, 0);
range.collapse(true);
sel?.removeAllRanges();
sel?.addRange(range);
}
}, [autoFocus]);
const emit = useCallback(() => {
const el = elRef.current;
if (!el) return;
const v = el.innerHTML;
lastEmitted.current = v;
setEmpty(!el.textContent?.trim() && !el.querySelector("img"));
onChange(v);
}, [onChange]);
const exec = useCallback(
(cmd: string, value?: string) => {
elRef.current?.focus();
restoreRange();
document.execCommand(cmd, false, value);
emit();
},
[emit],
);
const saveRange = () => {
const sel = window.getSelection();
if (sel && sel.rangeCount && elRef.current?.contains(sel.anchorNode)) savedRange.current = sel.getRangeAt(0).cloneRange();
};
const restoreRange = () => {
const r = savedRange.current;
if (!r) return;
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(r);
};
const insertHtml = useCallback(
(h: string) => {
elRef.current?.focus();
restoreRange();
document.execCommand("insertHTML", false, h);
emit();
},
[emit],
);
useImperativeHandle(ref, () => ({
focus: () => elRef.current?.focus(),
insertHtml,
insertText: (t: string) => {
elRef.current?.focus();
restoreRange();
document.execCommand("insertText", false, t);
emit();
},
getHtml: () => elRef.current?.innerHTML ?? "",
}));
const onPaste = (e: ClipboardEvent<HTMLDivElement>) => {
const items = Array.from(e.clipboardData.items);
const imgItem = items.find((i) => i.type.startsWith("image/"));
if (imgItem) {
const f = imgItem.getAsFile();
if (f) {
e.preventDefault();
insertImageFile(f);
return;
}
}
const htmlData = e.clipboardData.getData("text/html");
if (htmlData) {
e.preventDefault();
const clean = sanitizeEditorHtml(htmlData).replace(/<meta[^>]*>/gi, "");
document.execCommand("insertHTML", false, clean);
emit();
return;
}
// plain text: let browser handle (it inserts text nodes) but normalize newlines
const text = e.clipboardData.getData("text/plain");
if (text && /\n/.test(text)) {
e.preventDefault();
const escaped = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\r?\n/g, "<br>");
document.execCommand("insertHTML", false, escaped);
emit();
}
};
const insertImageFile = (f: File) => {
if (imageUpload) {
imageUpload(f)
.then((url) => insertHtml(`<img src="${url}" alt="${f.name.replace(/"/g, "")}" style="max-width:100%">`))
.catch(() => {
/* uploader reports its own errors */
});
return;
}
const reader = new FileReader();
reader.onload = () => {
insertHtml(`<img src="${reader.result as string}" alt="${f.name.replace(/"/g, "")}" style="max-width:100%">`);
};
reader.readAsDataURL(f);
};
const onDrop = (e: React.DragEvent<HTMLDivElement>) => {
const files = Array.from(e.dataTransfer.files);
if (!files.length) return;
e.preventDefault();
const images = files.filter((f) => f.type.startsWith("image/"));
const others = files.filter((f) => !f.type.startsWith("image/"));
images.forEach(insertImageFile);
if (others.length) onFiles?.(others);
};
const applyLink = () => {
const url = linkUrl.trim();
linkMenu.close();
if (!url) return;
const href = /^(https?:|mailto:|tel:)/i.test(url) ? url : `https://${url}`;
elRef.current?.focus();
restoreRange();
const sel = window.getSelection();
if (sel && sel.isCollapsed) document.execCommand("insertHTML", false, `<a href="${href}" target="_blank" rel="noopener">${href}</a>`);
else document.execCommand("createLink", false, href);
emit();
setLinkUrl("");
};
return (
<div className="composer-editor">
<div
ref={elRef}
className="editor-area"
contentEditable
suppressContentEditableWarning
spellCheck={spellcheck}
data-placeholder={placeholder ?? ""}
data-empty={empty}
onInput={emit}
onBlur={saveRange}
onKeyUp={saveRange}
onMouseUp={saveRange}
onPaste={onPaste}
onDrop={onDrop}
onDragOver={(e) => e.preventDefault()}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
saveRange();
linkMenu.open(e.currentTarget);
}
if (e.key === "Tab") {
e.preventDefault();
exec(e.shiftKey ? "outdent" : "indent");
}
}}
role="textbox"
aria-multiline="true"
aria-label="Message body"
/>
{showToolbar && (
<div className="editor-toolbar" role="toolbar" aria-label="Formatting">
<button type="button" className="icon-btn" title="Undo (Ctrl+Z)" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("undo")}><Undo size={16} /></button>
<button type="button" className="icon-btn" title="Redo" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("redo")}><Redo size={16} /></button>
<span className="tb-sep" />
<select title="Font size" onMouseDown={saveRange} onChange={(e) => { exec("fontSize", e.target.value); e.target.value = ""; }} defaultValue="">
<option value="" disabled>Size</option>
<option value="1">Small</option>
<option value="3">Normal</option>
<option value="5">Large</option>
<option value="7">Huge</option>
</select>
<button type="button" className="icon-btn" title="Bold (Ctrl+B)" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("bold")}><Bold size={16} /></button>
<button type="button" className="icon-btn" title="Italic (Ctrl+I)" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("italic")}><Italic size={16} /></button>
<button type="button" className="icon-btn" title="Underline (Ctrl+U)" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("underline")}><Underline size={16} /></button>
<button type="button" className="icon-btn" title="Strikethrough" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("strikeThrough")}><Strikethrough size={16} /></button>
<button type="button" className="icon-btn" title="Text color" onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={colorMenu.open}><Palette size={16} /></button>
<button type="button" className="icon-btn" title="Highlight" onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={hiliteMenu.open}><Highlighter size={16} /></button>
<span className="tb-sep" />
<button type="button" className="icon-btn" title="Align left" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyLeft")}><AlignLeft size={16} /></button>
<button type="button" className="icon-btn" title="Center" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyCenter")}><AlignCenter size={16} /></button>
<button type="button" className="icon-btn" title="Align right" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("justifyRight")}><AlignRight size={16} /></button>
<span className="tb-sep" />
<button type="button" className="icon-btn" title="Bulleted list" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("insertUnorderedList")}><List size={16} /></button>
<button type="button" className="icon-btn" title="Numbered list" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("insertOrderedList")}><ListOrdered size={16} /></button>
<button type="button" className="icon-btn" title="Decrease indent" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("outdent")}><Outdent size={16} /></button>
<button type="button" className="icon-btn" title="Increase indent" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("indent")}><Indent size={16} /></button>
<button type="button" className="icon-btn" title="Quote" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "blockquote")}><Quote size={16} /></button>
<button type="button" className="icon-btn" title="Code block" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "pre")}><Code size={16} /></button>
<button type="button" className="icon-btn" title="Normal text" onMouseDown={(e) => e.preventDefault()} onClick={() => exec("formatBlock", "div")}><Type size={16} /></button>
<span className="tb-sep" />
<button type="button" className="icon-btn" title="Insert link (Ctrl+K)" onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={linkMenu.open}><LinkIcon size={16} /></button>
<label className="icon-btn" title="Insert image" onMouseDown={saveRange}>
<ImageIcon size={16} />
<input type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) insertImageFile(f); e.target.value = ""; }} />
</label>
<button type="button" className="icon-btn" title="Emoji" onMouseDown={(e) => { e.preventDefault(); saveRange(); }} onClick={emojiMenu.open}><Smile size={16} /></button>
<button type="button" className="icon-btn" title="Remove formatting" onMouseDown={(e) => e.preventDefault()} onClick={() => { exec("removeFormat"); exec("unlink"); }}><Eraser size={16} /></button>
{toolbarExtra}
</div>
)}
<Popover anchor={emojiMenu.anchor} onClose={emojiMenu.close} side="top" closeOnClick={false} width={290}>
<div className="emoji-grid">
{EMOJI.map((e) => (
<button key={e} type="button" onMouseDown={(ev) => ev.preventDefault()} onClick={() => { insertHtml(e); emojiMenu.close(); }}>{e}</button>
))}
</div>
</Popover>
<Popover anchor={colorMenu.anchor} onClose={colorMenu.close} side="top" closeOnClick={false} width={230}>
<div className="color-grid">
{COLORS.map((c) => <button key={c} type="button" style={{ background: c }} onMouseDown={(ev) => ev.preventDefault()} onClick={() => { exec("foreColor", c); colorMenu.close(); }} aria-label={c} />)}
</div>
</Popover>
<Popover anchor={hiliteMenu.anchor} onClose={hiliteMenu.close} side="top" closeOnClick={false} width={230}>
<div className="color-grid">
{COLORS.map((c) => <button key={c} type="button" style={{ background: c }} onMouseDown={(ev) => ev.preventDefault()} onClick={() => { exec("hiliteColor", c); hiliteMenu.close(); }} aria-label={c} />)}
</div>
</Popover>
<Popover anchor={linkMenu.anchor} onClose={linkMenu.close} side="top" closeOnClick={false} width={320}>
<form className="link-popup" onSubmit={(e) => { e.preventDefault(); applyLink(); }}>
<input className="input sm" autoFocus placeholder="https://…" value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)} />
<button type="submit" className="btn btn-sm btn-primary">Link</button>
</form>
</Popover>
</div>
);
});