import { useQuery } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; import { useT } from "../../i18n"; import { api, ApiError, isOfflineError } from "../../lib/api"; import { isOffline, useOffline } from "../../lib/offline"; import { deleteFiles, deleteLocal, holdOpen, type LocalFile, type LocalMessage, type LocalState, newLocalId, putFile, putLocal, uploadFiles, } from "../../lib/scheduled"; import type { AccountOut, AttachmentOut, DraftInput, DraftOut } from "../../lib/types"; import { showOfflineNotice } from "../../lib/offlineNotice"; import { RecipientChips } from "./RecipientChips"; import { RichTextEditor, type RichTextEditorHandle } from "./RichTextEditor"; import { hasUndoHost, offerUndo } from "./undoSend"; const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB per file export interface ComposerInit { accountId: string; threadId?: string | null; replyToMessageId?: string | null; kind: string; // reply | reply_all | forward | new to?: string; cc?: string; bcc?: string; subject?: string; text?: string; html?: string; // initial body when reopening an existing draft draftId?: string; // when reopening an existing draft attachmentIds?: string[]; // compose-attachment ids when reopening a draft // A reply/forward quotes the original: this attribution line sits above the // quoted message body (fetched from the original message's sanitized HTML). quote?: { attribution: string; messageId: string }; // A message kept on this device (written offline; lib/scheduled.ts). localId?: string; localState?: LocalState; // "scheduled" when reopened from Scheduled attachments?: AttachmentOut[]; // known already: no need to ask the server localFiles?: LocalFile[]; } // Build the composer seed from a saved draft (used to reopen it in place or in a // popped-out window). export function draftToInit(draft: DraftOut): ComposerInit { return { accountId: draft.account_id, threadId: draft.thread_id, replyToMessageId: draft.reply_to_message_id, kind: draft.kind, to: draft.payload.to.join(", "), cc: draft.payload.cc.join(", "), subject: draft.payload.subject, text: draft.payload.text, html: draft.payload.html || undefined, attachmentIds: draft.payload.attachment_ids, draftId: draft.id, }; } // Reopen a message kept on this device (a draft written offline, or one in // Scheduled). export function localToInit(msg: LocalMessage): ComposerInit { const p = msg.input.payload; return { accountId: msg.input.account_id, threadId: msg.input.thread_id ?? null, replyToMessageId: msg.input.reply_to_message_id ?? null, kind: msg.input.kind, to: p.to.join(", "), cc: p.cc.join(", "), bcc: p.bcc.join(", "), subject: p.subject, text: p.text, html: p.html || undefined, draftId: msg.serverDraftId ?? undefined, localId: msg.id, localState: msg.state === "draft" ? "draft" : "scheduled", // "failed"/"sending" wait to be sent too attachments: msg.attachments, localFiles: msg.files, }; } function splitList(value: string): string[] { return value .split(",") .map((s) => s.trim()) .filter(Boolean); } function htmlToText(html: string): string { const div = document.createElement("div"); div.innerHTML = html; return div.textContent ?? ""; } function escapeHtml(value: string): string { return value.replace(/&/g, "&").replace(//g, ">"); } function textToHtml(text: string): string { return text .split("\n") .map((line) => `
${escapeHtml(line) || "
"}
`) .join(""); } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } // A single header icon-button (Attach / window controls / etc.). function HeaderButton({ onClick, title, active = false, className = "", children, }: { onClick?: () => void; title: string; active?: boolean; className?: string; children: React.ReactNode; }) { return ( ); } // Send / Attach / Close icons, half again as big at phone width for the thumb. const PHONE_ICON = "max-md:h-[22.5px] max-md:w-[22.5px]"; const caret = ( ); export function Composer({ init, onClose, expanded = false, onToggleExpand, minimized = false, onMinimize, onTitle, fitContainer = false, fullWindow = false, }: { init: ComposerInit; onClose: () => void; expanded?: boolean; onToggleExpand?: () => void; // Collapsed to a title bar in the dock. The whole subtree stays mounted — // everything below the header is hidden with CSS, never unmounted, because // RichTextEditor seeds its contentEditable once and a remount would throw the // typed body away. minimized?: boolean; onMinimize?: () => void; // Reports the live subject so the dock's overflow menu can name a window by // what was typed into it, not by the (static) subject it was opened with. onTitle?: (title: string) => void; // The container fixes this composer's height, so a long body scrolls instead // of pushing the window taller. False only for the inline reply inside a // thread, which grows with what you write and scrolls with the thread. fitContainer?: boolean; fullWindow?: boolean; }) { // Whether something other than the content decides how tall this is. const sized = fullWindow || fitContainer; const t = useT(); const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts }); const accounts: AccountOut[] = accountsQuery.data ?? []; const [accountId, setAccountId] = useState(init.accountId); const [to, setTo] = useState(init.to ?? ""); const [cc, setCc] = useState(init.cc ?? ""); const [bcc, setBcc] = useState(init.bcc ?? ""); const [showCc, setShowCc] = useState(Boolean(init.cc || init.bcc)); const [subject, setSubject] = useState(init.subject ?? ""); const [bodyHtml, setBodyHtml] = useState(init.html ?? ""); const [quoteHtml, setQuoteHtml] = useState(""); const [quoteResolved, setQuoteResolved] = useState(!init.quote); const [showToolbar, setShowToolbar] = useState(true); const [fromOpen, setFromOpen] = useState(false); const [moreOpen, setMoreOpen] = useState(false); // This message's spell-check language (More menu); null follows Settings. const [spellLang, setSpellLang] = useState(null); const [spellMenuOpen, setSpellMenuOpen] = useState(false); const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings }); // The language list folds away whenever the More menu closes. useEffect(() => { if (!moreOpen) setSpellMenuOpen(false); }, [moreOpen]); // What the engine can check — and whether this server has one at all. const spellLanguages = useQuery({ queryKey: ["spellcheck-languages"], queryFn: api.spellcheckLanguages, retry: false, staleTime: 3_600_000, }); const moreRef = useRef(null); const fromRef = useRef(null); const [attachments, setAttachments] = useState(init.attachments ?? []); // Files attached while offline, kept on this device until they can be uploaded. const [localFiles, setLocalFiles] = useState(init.localFiles ?? []); const [attachError, setAttachError] = useState(""); const [status, setStatus] = useState<"" | "saving" | "saved" | "savedLocal" | "sending" | "error">(""); const offline = useOffline(); const [undo, setUndo] = useState<{ outboxId: string; until: number } | null>(null); const [undoLeft, setUndoLeft] = useState(0); // Tick the undo countdown while the window is open. useEffect(() => { if (!undo) return; const id = window.setInterval( () => setUndoLeft(Math.max(0, Math.ceil((undo.until - Date.now()) / 1000))), 500, ); return () => window.clearInterval(id); }, [undo]); const [showClosePrompt, setShowClosePrompt] = useState(false); // Through a ref so a fresh callback identity on every parent render can't turn // this into a render loop. const onTitleRef = useRef(onTitle); onTitleRef.current = onTitle; useEffect(() => { onTitleRef.current?.(subject); }, [subject]); const draftId = useRef(init.draftId ?? null); // This message's copy on this device, while there is one (lib/scheduled.ts). const localId = useRef(init.localId ?? null); const discardedRef = useRef(false); // Kept as what it was opened as: a scheduled message edited offline stays scheduled. const localState = useRef(init.localState ?? "draft"); const sentRef = useRef(false); // send initiated -> don't mirror to Drafts on close // Whether the user actually edited anything. Merely opening a draft to view it // must not re-save/re-mirror it (that spawned duplicate IMAP copies). const dirtyRef = useRef(false); const markDirty = () => { dirtyRef.current = true; }; const undoTimer = useRef(null); const fileInputRef = useRef(null); const editorRef = useRef(null); // Close the From / More dropdowns on any click outside them. useEffect(() => { if (minimized || (!moreOpen && !fromOpen)) return; const onDown = (e: MouseEvent) => { const target = e.target as Node; if (moreOpen && moreRef.current && !moreRef.current.contains(target)) setMoreOpen(false); if (fromOpen && fromRef.current && !fromRef.current.contains(target)) setFromOpen(false); }; document.addEventListener("mousedown", onDown); return () => document.removeEventListener("mousedown", onDown); }, [moreOpen, fromOpen, minimized]); const account = accounts.find((a) => a.id === accountId); const fromName = account?.display_name || ""; const fromEmail = account?.email ?? ""; const signature = account?.signature ?? ""; // When the From account changes, swap the signature block for the new one. // // Keyed on the account rather than a "first run" flag: under StrictMode the // mount effect runs twice, so a flag is already spent by the second pass, // which then rewrites the signature of an untouched composer — marking it // dirty and leaving a blank draft behind for every window opened. const lastAccount = useRef(init.accountId); useEffect(() => { if (lastAccount.current === accountId) return; lastAccount.current = accountId; editorRef.current?.replaceSignature(accounts.find((a) => a.id === accountId)?.signature ?? ""); // eslint-disable-next-line react-hooks/exhaustive-deps }, [accountId]); // Fetch the original message's sanitized body to quote in a reply/forward. useEffect(() => { const quote = init.quote; if (!quote) return; let cancelled = false; (async () => { try { const resp = await fetch(api.messageHtmlUrl(quote.messageId), { credentials: "include" }); // Offline, a message not saved here answers 503: quote nothing rather than that. if (!resp.ok) throw new Error(`quote: ${resp.status}`); const doc = new DOMParser().parseFromString(await resp.text(), "text/html"); if (!cancelled) setQuoteHtml(doc.body?.innerHTML ?? ""); } catch { /* leave the quote empty on failure */ } finally { if (!cancelled) setQuoteResolved(true); } })(); return () => { cancelled = true; }; }, [init.quote]); // Resolve attachment metadata when reopening a draft that already has some. useEffect(() => { const ids = init.attachmentIds ?? []; if (ids.length === 0 || init.attachments) return; let cancelled = false; (async () => { try { const rows = await api.composeAttachments(ids); if (!cancelled) setAttachments(rows); } catch { /* ignore */ } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const addFiles = async (files: FileList | File[]) => { markDirty(); setAttachError(""); for (const file of Array.from(files)) { if (file.size > MAX_ATTACHMENT_BYTES) { setAttachError(t("compose.attachTooLarge")); continue; } try { if (isOffline()) { // Kept on this device, uploaded once the server is reachable. const kept = await putFile(file); setLocalFiles((prev) => [...prev, kept]); continue; } const att = await api.uploadComposeAttachment(file); setAttachments((prev) => [...prev, att]); } catch (e) { setAttachError(e instanceof ApiError ? e.message : t("compose.attachTooLarge")); } } }; const removeAttachment = (id: string) => { markDirty(); setAttachments((prev) => prev.filter((a) => a.id !== id)); }; const removeLocalFile = (id: string) => { markDirty(); setLocalFiles((prev) => prev.filter((f) => f.id !== id)); void deleteFiles([id]); }; // Drag & drop: files dropped on the writing area insert inline (images) or // attach (other types); dropped anywhere above it, everything attaches. const editorAreaRef = useRef(null); const [dragKind, setDragKind] = useState(null); const readDataUrl = (file: File): Promise => new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(String(r.result)); r.onerror = () => reject(r.error); r.readAsDataURL(file); }); const setCaretToPoint = (x: number, y: number) => { const d = document as Document & { caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null; }; let range: Range | null = null; if (d.caretRangeFromPoint) range = d.caretRangeFromPoint(x, y); else if (d.caretPositionFromPoint) { const p = d.caretPositionFromPoint(x, y); if (p) { range = document.createRange(); range.setStart(p.offsetNode, p.offset); range.collapse(true); } } if (range && editorAreaRef.current?.contains(range.startContainer)) { const sel = window.getSelection(); sel?.removeAllRanges(); sel?.addRange(range); } }; const onDragOver = (e: React.DragEvent) => { if (!Array.from(e.dataTransfer.types).includes("Files")) return; e.preventDefault(); setDragKind(editorAreaRef.current?.contains(e.target as Node) ? "body" : "attach"); }; const onDragLeave = (e: React.DragEvent) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragKind(null); }; const onDrop = async (e: React.DragEvent) => { const files = e.dataTransfer.files; if (!files || !files.length) return; e.preventDefault(); const inBody = editorAreaRef.current?.contains(e.target as Node); setDragKind(null); if (!inBody) { void addFiles(files); return; } setCaretToPoint(e.clientX, e.clientY); const others: File[] = []; for (const file of Array.from(files)) { if (file.type.startsWith("image/")) { try { editorRef.current?.insertHtml(``); markDirty(); } catch { others.push(file); // fall back to attaching if the read fails } } else { others.push(file); } } if (others.length) void addFiles(others); }; // The editor's seed content. New messages get the account signature (2 blank // lines above it); replies keep the quoted original in a marked block so the // signature can later be inserted above it. const initialHtml = (() => { if (init.html) return init.html; if (init.text) return textToHtml(init.text); if (init.quote) { return ( `

` + `
` + `
${escapeHtml(init.quote.attribution)}
` + `
${quoteHtml}
` ); } return `


${signature}
`; })(); const bodyReady = quoteResolved && accountsQuery.data !== undefined; const payload = (atts: AttachmentOut[] = attachments): DraftInput => { const html = bodyHtml || initialHtml; return { account_id: accountId, thread_id: init.threadId ?? null, reply_to_message_id: init.replyToMessageId ?? null, kind: init.kind, payload: { to: splitList(to), cc: splitList(cc), bcc: splitList(bcc), subject, html, text: htmlToText(html), attachment_ids: atts.map((a) => a.id), }, }; }; // Keep this message on the device (offline): as a draft, or for Scheduled. const saveLocal = async (state: LocalState) => { localId.current ??= newLocalId(); const msg: LocalMessage = { id: localId.current, state, input: payload(), attachments, files: localFiles, serverDraftId: draftId.current, updatedAt: Date.now(), }; await putLocal(msg); }; // While a copy is kept here, other tabs' hand-over to the server leaves it be. const [heldId, setHeldId] = useState(localId.current); useEffect(() => (heldId ? holdOpen(heldId) : undefined), [heldId]); // Save to the server; a copy kept on this device goes there first (its files // uploaded), then is dropped. Returns the attachments now on the server. const saveToServer = async (): Promise => { let atts = attachments; if (localFiles.length) { const uploaded = await uploadFiles(localFiles); await deleteFiles(localFiles.map((f) => f.id)); atts = [...attachments, ...uploaded]; setAttachments(atts); setLocalFiles([]); } if (draftId.current) await api.updateDraft(draftId.current, payload(atts)); else draftId.current = (await api.createDraft(payload(atts))).id; if (localId.current) { await deleteLocal(localId.current); localId.current = null; setHeldId(null); } localState.current = "draft"; // on the server now: a draft until Send return atts; }; const autosave = async () => { setStatus("saving"); try { if (isOffline()) throw new ApiError(0, "offline", "offline"); await saveToServer(); setStatus("saved"); } catch (e) { if (!isOfflineError(e)) { setStatus("error"); return; } try { await saveLocal(localState.current); setHeldId(localId.current); setStatus("savedLocal"); } catch { setStatus("error"); } } }; useEffect(() => { if (undo) return; const handle = window.setTimeout(() => { if (!dirtyRef.current) return; // nothing the user changed -> don't touch the draft if (!to && !subject && !bodyHtml) return; void autosave(); }, 2000); return () => window.clearTimeout(handle); // eslint-disable-next-line react-hooks/exhaustive-deps }, [accountId, to, cc, bcc, subject, bodyHtml, attachments, localFiles]); // Back online with a draft kept here: hand it to the server now. (A // scheduled one is sent by the scheduled sender once this window closes.) useEffect(() => { if (!offline && localId.current && !undo && localState.current === "draft") void autosave(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [offline]); // The latest way to save locally, for the unmount below (its closure is old). const saveLocalRef = useRef(saveLocal); saveLocalRef.current = saveLocal; useEffect( () => () => { if (undoTimer.current) window.clearTimeout(undoTimer.current); // On close, mirror the draft to the IMAP Drafts folder so it can be // continued on any mail client — but only if the user actually edited it // (skip if it was sent, discarded, or merely opened to view). if (localId.current && localState.current === "scheduled" && !sentRef.current && !discardedRef.current) { // A scheduled message looked at (or edited offline) and closed: still // scheduled — save any change, then let the sender have it. const save = dirtyRef.current ? saveLocalRef.current("scheduled") : Promise.resolve(); void save.finally(() => window.dispatchEvent(new Event("webmail:send-scheduled"))); return; } if (sentRef.current || discardedRef.current || !dirtyRef.current) return; if (isOffline()) { // Closed offline: keep it here as a draft (the server gets it later). void saveLocalRef.current("draft").catch(() => {}); return; } if (draftId.current && !localId.current) { void api.publishDraft(draftId.current).catch(() => {}); } }, [], ); // Offline (or the connection dropped while sending): into Scheduled, to go // out on its own once the server is reachable again. const schedule = async () => { try { await saveLocal("scheduled"); sentRef.current = true; showOfflineNotice("scheduled", 5000); onClose(); } catch { sentRef.current = false; setStatus("error"); } }; const send = async () => { setStatus("sending"); sentRef.current = true; // don't also mirror to Drafts on close if (isOffline()) { await schedule(); return; } try { await saveToServer(); const sentDraft = draftId.current!; const res = await api.send(sentDraft); // Drive the countdown + auto-close off the server's undo window, as a // duration: this computer's clock may be seconds off the server's. const until = Date.now() + res.undo_seconds * 1000; // With a toast to carry the undo offer, the window closes right away. if (hasUndoHost()) { offerUndo({ outboxId: res.outbox_id, draftId: sentDraft, until }); onClose(); return; } const remaining = Math.max(0, until - Date.now()); setUndo({ outboxId: res.outbox_id, until }); setUndoLeft(Math.ceil(remaining / 1000)); undoTimer.current = window.setTimeout(onClose, remaining); } catch (e) { if (isOfflineError(e)) { await schedule(); return; } sentRef.current = false; setStatus("error"); if (!(e instanceof ApiError)) throw e; } }; const doUndo = async () => { if (!undo) return; try { await api.undoSend(undo.outboxId); } catch { /* too late */ } if (undoTimer.current) window.clearTimeout(undoTimer.current); sentRef.current = false; // back to a draft; mirror it on close setUndo(null); setStatus(""); }; const discard = async () => { discardedRef.current = true; if (localId.current) { await deleteLocal(localId.current).catch(() => {}); localId.current = null; } else if (localFiles.length) { await deleteFiles(localFiles.map((f) => f.id)).catch(() => {}); } if (draftId.current && !isOffline()) { try { await api.deleteDraft(draftId.current); } catch { /* ignore */ } draftId.current = null; } onClose(); }; // X button: if there's a draft worth keeping, ask before closing; otherwise // (nothing typed, no draft) just close. const requestClose = () => { if (!dirtyRef.current && !draftId.current && !localId.current) { onClose(); return; } // The prompt is an overlay on this window; in a collapsed title bar there is // nowhere to draw it. Restore first, then ask. if (minimized) onMinimize?.(); setShowClosePrompt(true); }; const saveDraftAndClose = async () => { setShowClosePrompt(false); try { if (dirtyRef.current) await autosave(); } catch { /* the close-time IMAP mirror is best-effort too */ } onClose(); // the unmount effect mirrors the (dirty) draft to the IMAP Drafts folder }; const popOut = async () => { // Persist first so the new window can load this exact draft. try { if (!draftId.current) draftId.current = (await api.createDraft(payload())).id; else await api.updateDraft(draftId.current, payload()); } catch { return; } // A window a quarter of the screen area (half width × half height), centered. const w = Math.round(window.screen.width / 2); const h = Math.round(window.screen.height / 2); const left = Math.round((window.screen.width - w) / 2); const top = Math.round((window.screen.height - h) / 2); window.open( `/compose/${draftId.current}`, `compose-${draftId.current}`, `width=${w},height=${h},left=${left},top=${top}`, ); onClose(); }; if (undo) { return (
{t("compose.sending")} {undoLeft > 0 && ( {t("compose.undoIn", { count: undoLeft })} )}
); } return (
{dragKind && (
{dragKind === "body" ? t("compose.dropBody") : t("compose.dropAttach")}
)} {showClosePrompt && (
setShowClosePrompt(false)} >
e.stopPropagation()} >

{t("compose.closePrompt")}

)} {/* Header: Send · Attach · formatting toggle · More · window controls */}
{minimized ? ( // Collapsed: the subject stands in for the action buttons, and the whole // bar is the restore target. ) : ( <> fileInputRef.current?.click()} title={t("compose.attach")}> {/* Phone width: the paperclip alone, so the close button still fits. */} {t("compose.attach")} { if (e.target.files && e.target.files.length) void addFiles(e.target.files); e.target.value = ""; }} /> setShowToolbar((v) => !v)} title={t("compose.fmtBold")} active={showToolbar} > A
setMoreOpen((v) => !v)} title={t("compose.more")}> {t("compose.more")} {moreOpen && (
{spellLanguages.data && (() => { // The language in force: this message's choice, else Settings. const fallback = settingsQuery.data?.spellcheck_enabled ? (settingsQuery.data?.spellcheck_language ?? "auto") : "off"; const current = spellLang ?? fallback; const nameOf = (code: string) => code === "off" ? t("compose.spellOff") : code === "auto" ? t("compose.spellAuto") : (spellLanguages.data.find((l) => l.code === code)?.name ?? code); const choices = [ { code: "off", name: t("compose.spellOffMessage") }, { code: "auto", name: t("compose.spellAuto") }, ...spellLanguages.data, ]; return ( <> {spellMenuOpen && (
{choices.map((c) => ( ))}
)} ); })()}
)}
)}
{!minimized && status === "saving" && {t("compose.savingDraft")}} {!minimized && status === "saved" && {t("compose.draftSaved")}} {!minimized && status === "savedLocal" && {t("compose.savedOnDevice")}} {!minimized && status === "error" && ( {t("compose.sendFailed")} )} {!fullWindow && onMinimize && ( {minimized ? ( ) : ( )} )} {!fullWindow && onToggleExpand && ( {expanded ? ( ) : ( )} )} {!fullWindow && ( // No second browser window to pop out into on a phone.
)}
{/* Hidden rather than unmounted while collapsed: the editor seeds its contentEditable once, so dropping this subtree would lose the body. */}
{/* From */}
{t("compose.from")} {fromOpen && (
{accounts.map((a) => ( ))}
)}
{/* Recipients */} { markDirty(); setTo(v); }} autoFocus={init.kind === "new" || init.kind === "forward"} rightSlot={ !showCc ? ( ) : undefined } /> {showCc && ( <> { markDirty(); setCc(v); }} /> { markDirty(); setBcc(v); }} /> )} { markDirty(); setSubject(e.target.value); }} /> {(attachments.length > 0 || localFiles.length > 0 || attachError) && (
{attachments.map((att) => ( {att.filename} {formatBytes(att.size_bytes)} ))} {localFiles.map((f) => ( {f.name} {formatBytes(f.size)} ))} {attachError && {attachError}}
)} {/* Body — wrapped so a drop here is detected as the writing area. */}
{bodyReady ? ( { markDirty(); setBodyHtml(html); }} showToolbar={showToolbar} spellLanguage={spellLang} // With a height to divide, the body takes what is left and // scrolls. A floor here would instead push the window past that // height — and `overflow-hidden` would clip the difference. minHeight={sized ? "min-h-0" : undefined} /> ) : (
…
)}
); }