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(null); const fileRef = useRef(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; // Where the caret starts, decided once when the composer opens: a blank // message starts in the recipients, a reply (already addressed and titled) // starts in the body. Deriving this from live state would move the caret // while the user types. const [initialFocus] = useState(() => initialFocusTarget(draft)); const patch = useCallback((p: Partial) => 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(/
[\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, "
") }); } }; 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 (
focus(key)}>
{title}
); } return (
{ if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } }} onDragLeave={() => setDropping(false)} onDrop={onDrop} role="dialog" aria-label="Compose message">
patch({ maximized: !d.maximized })}> {title} {status} {!isMobile && } {!isMobile && }
{identities.length > 1 && (
)}
patch({ to })} placeholder="Recipients" autoFocus={initialFocus === "to"} /> {!d.showCc && } {!d.showBcc && } {!d.showReplyTo && }
{d.showReplyTo && (
patch({ replyTo })} placeholder="Replies go to…" />
)} {d.showCc && (
patch({ cc })} />
)} {d.showBcc && (
patch({ bcc })} />
)}
patch({ subject: e.target.value })} autoFocus={initialFocus === "subject"} /> {d.priority !== "normal" && {d.priority === "high" ? "High priority" : "Low priority"}} {d.requestReceipt && }
{d.format === "html" ? ( addFiles(key, files)} showToolbar={showToolbar} autoFocus={initialFocus === "body"} /> ) : (