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/text"; import { isValidEmail, uniqueAddresses } from "@/lib/address"; import { crossesRecipientThreshold, externalRecipients, internalDomains } from "@/lib/warnings"; import { attachmentIcon } from "../mail/MessageView"; import { FilePicker } from "./FilePicker"; import { RecipientPicker, type Field } from "./RecipientPicker"; import { useFiles } from "@/store/files"; import { keyboard } from "@/lib/input/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(translate("Please add at least one recipient")); return; } const bad = all.filter((a) => !isValidEmail(a.email)); if (bad.length) { toast.error(translate("Invalid address: {address}", { address: bad[0]!.email })); return; } if (d.attachments.some((a) => a.error)) { toast.error(translate("Remove attachments that failed to upload first")); return; } if (d.attachments.some((a) => !a.blobId)) { toast.error(translate("Attachments are still uploading")); return; } if (!d.subject.trim()) { const ok = await confirmDialog({ title: translate("Send without a subject?"), confirmLabel: translate("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: translate("Did you forget the attachment?"), message: translate("Your message mentions an attachment, but nothing is attached."), confirmLabel: translate("Send anyway") }); if (!ok) return; } /* * The two send-time warnings, in this order because they answer different * questions and a message can trip both: who it is going to, then how many * of them. Both name the specific thing rather than warning in general -- * "this is going outside" is a rule, "this is going to ada@outside.net" is * something the sender can check. */ if (settings.externalRecipientConfirm) { // allIdentities, not the visible subset: hiding an identity from the // picker is about the From menu, and does not make its domain // somebody else's. const outside = externalRecipients(all, internalDomains(allIdentities.map((i) => i.email), settings.internalDomains)); if (outside.length) { const names = outside.slice(0, 5).map((a) => a.email).join(", "); const rest = outside.length > 5 ? translate(" and {count} more", { count: String(outside.length - 5) }) : ""; const ok = await confirmDialog({ title: translate("Send outside your organization?"), message: translate("This goes to {recipients}{rest}.", { recipients: names, rest }), confirmLabel: translate("Send anyway"), }); if (!ok) return; } } if (crossesRecipientThreshold(uniqueAddresses(all).length, settings.replyAllThreshold)) { const ok = await confirmDialog({ title: translate("Send to {count} people?", { count: String(uniqueAddresses(all).length) }), message: translate("Everyone addressed will receive this."), confirmLabel: translate("Send anyway"), }); if (!ok) return; } await send(key); }; // inbuxa: DLP warned: send anyway with a reason the server records const sendAnyway = async () => { const reason = await promptDialog({ title: translate("Send anyway?"), message: translate("Your reason is recorded with the message."), placeholder: translate("Why this needs to go"), confirmLabel: translate("Send anyway"), }); if (!reason?.trim()) return; patch({ dlp: null, dlpOverride: reason.trim() }); await send(key); }; const scheduleFor = (at: Date) => { sendMenu.close(); setScheduleOpen(false); patch({ sendAt: at.getTime() }); }; /* * Switching format converts what has been written, but the quoted message * is not something this draft wrote: it was prepared in both formats when * the reply opened. Converting the plain-text quote into HTML would hand * back a flattened copy of a message that still exists in its original * markup, so re-attach that instead, and keep only what the author typed * above it. Where the quote can no longer be found -- edited, or a draft * that quotes nothing -- convert the whole body as before. */ const toggleFormat = () => { // Whichever way the format is changed, the offer has been answered. if (d.format === "html") { const at = d.quoteHtml ? d.html.indexOf('
') : -1; const written = at >= 0 ? htmlToText(d.html.slice(0, at)) : htmlToText(d.html); patch({ format: "text", text: at >= 0 ? written.replace(/\s+$/, "") + d.quoteText : written, formatOffer: null }); } else { const keeps = Boolean(d.quoteText) && d.text.endsWith(d.quoteText); const written = keeps ? d.text.slice(0, d.text.length - d.quoteText.length) : d.text; const asHtml = textToHtml(written, { linkify: false, quoteColors: false }).replace(/\n/g, "
"); patch({ format: "html", html: keeps ? asHtml + d.quoteHtml : asHtml, formatOffer: null }); } }; 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" ? translate("Forward") : translate("Reply")) : translate("New message")); const status = d.sending ? translate("Sending…") : d.saving ? translate("Saving…") : d.error ? translate("Error") : d.savedAt ? translate("Saved {when}", { when: formatRelative(new Date(d.savedAt).toISOString()) }) : d.dirty ? translate("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 && ( )}
{/* Replying in one format to a message written in the other loses something either way: the formatting of a rich reply, or the plain text somebody chose to write in. The draft opens in the format the settings ask for, and this offers the other one for this message only, rather than quietly overriding the setting (#407). */} {d.formatOffer && (
{d.formatOffer === "html" ? translate("This message is rich text") : translate("This message is plain text")}
)} {/* inbuxa: the server's DLP rules refused the last send. A warning can be answered with a reason; a block can't, only by changing the message. */} {d.dlp && (
r.notice).join(" ")}> {d.dlp.kind === "warning" ? translate("Not sent yet:") : translate("Not sent:")} {d.dlp.rules.map((r) => r.notice).join(" ")} {d.dlp.kind === "warning" && ( )}
)} {d.format === "html" ? ( addFiles(key, files)} showToolbar={showToolbar} autoFocus={initialFocus === "body"} /> ) : (