import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AlertTriangle, BookUser, ChevronDown, FileText, FolderOpen, 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 { visibleIdentities } from "@/lib/identityVisibility"; 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 { FilePicker } from "./FilePicker"; import { RecipientPicker, type Field } from "./RecipientPicker"; import { useFiles } from "@/store/files"; import { keyboard } from "@/lib/keyboard"; import { useIsMobile } from "@/ui/misc"; import { toast } from "@/ui/toast"; import { ScheduleDialog, ScheduleMenuItems } from "./SchedulePicker"; import { scheduleSupported, scheduleWindowMs } from "@/store/scheduled"; import { formatScheduleTime } from "@/lib/schedule"; import { t as translate } from "@/lib/i18n"; 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 addFromFiles = useCompose((s) => s.addFromFiles); const filesAvailable = useFiles((s) => s.available); const [pickerOpen, setPickerOpen] = useState(false); const [addressBookOpen, setAddressBookOpen] = useState(false); 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 allIdentities = useMail((s) => s.identities); const mailAccountId = useMail((s) => s.accountId); const hiddenIdentities = useSettings((s) => s.settings.hiddenIdentities); const defaultIdentityId = useSettings((s) => (mailAccountId ? s.settings.defaultIdentityByAccount[mailAccountId] : undefined)); 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 [scheduleOpen, setScheduleOpen] = useState(false); // Read once per render from the session; it cannot change while a composer is open. const canSchedule = scheduleSupported(); const scheduleMax = canSchedule ? scheduleWindowMs() : 0; 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 scheduleFor = (at: Date) => { sendMenu.close(); setScheduleOpen(false); patch({ sendAt: at.getTime() }); }; 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); }; /* * The picker offers the visible identities, plus two that can never be * hidden from it: the one this draft is already using, and the default a new * draft starts on. Hiding either would leave the select with no matching * option and silently move the From line. See lib/identityVisibility. */ const identities = useMemo( () => visibleIdentities(allIdentities, hiddenIdentities, [d.identityId, defaultIdentityId]), [allIdentities, hiddenIdentities, d.identityId, defaultIdentityId], ); 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={translate("Compose message")}>
patch({ maximized: !d.maximized })}> {title} {status} {!isMobile && } {!isMobile && }
{identities.length > 1 && (
)}
patch({ to })} placeholder={translate("Recipients")} autoFocus={initialFocus === "to"} /> {/* Beside Cc and Bcc, because that is where someone looks when they are thinking about who the message goes to. The label opens it too, for anyone who tries that first. */} {!d.showCc && } {!d.showBcc && } {!d.showReplyTo && }
{d.showReplyTo && (
patch({ replyTo })} placeholder={translate("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.sendAt !== null && ( )}
{d.format === "html" ? ( addFiles(key, files)} showToolbar={showToolbar} autoFocus={initialFocus === "body"} /> ) : (