"use client"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { Send, Paperclip, Bold, Italic, Underline, Strikethrough, List, ListOrdered, Link as LinkIcon, Undo2, X, Sparkles, CheckCircle2, Flag, } from "lucide-react"; import { useTranslations } from "../i18n/provider"; import { Button } from "../components/ui/Button"; import { notifyMailStateChanged } from "../lib/useFolders"; import { signatureToHtml } from "../lib/signature"; import { RecipientInput } from "../components/RecipientInput"; import { parseAddress, parseAddressList, prefixSubject } from "../lib/recipients"; /** Entries from a query-string list, which may be several addresses. */ function entriesFrom(value: string | undefined): string[] { return value ? parseAddressList(value).map((p) => p.entry) : []; } interface AttachedFile { id: string; name: string; size: number; type: string; } export interface ComposerInitial { replyTo?: string; cc?: string; subject?: string; inReplyTo?: string; mode?: string; draft?: string; } interface ComposerProps { initial?: ComposerInitial; /** * "page" is the full compose screen. "inline" is the reply written under the * conversation it answers: no title, no subject row (a reply keeps its * subject) and a shorter editor, so the messages stay in view above it. */ variant?: "page" | "inline"; onSent: () => void; onDiscarded: () => void; } export function Composer({ initial, variant = "page", onSent, onDiscarded }: ComposerProps) { const t = useTranslations(); const inline = variant === "inline"; const params = initial ?? {}; // Recipients are lists of entries, not one string to be split later: a // reply-all arrives with several people already in To and Cc. const [to, setTo] = useState(() => entriesFrom(params?.replyTo)); const [cc, setCc] = useState(() => entriesFrom(params?.cc)); const [bcc, setBcc] = useState([]); const [showCcBcc, setShowCcBcc] = useState(() => entriesFrom(params?.cc).length > 0); const [subject, setSubject] = useState( params?.subject ? prefixSubject(params.subject, params.mode === "forward" ? "forward" : "reply") : "", ); // The Message-ID being answered, so the reply threads under it. const [inReplyTo, setInReplyTo] = useState(params?.mode === "forward" ? "" : params?.inReplyTo || ""); const [important, setImportant] = useState(false); const [attachments, setAttachments] = useState([]); const [sending, setSending] = useState(false); const [undoSeconds, setUndoSeconds] = useState(null); const [draftStatus, setDraftStatus] = useState(""); const [discarding, setDiscarding] = useState(false); const [sendError, setSendError] = useState(null); // The UID of the draft already stored for this message. Sent back on each // autosave so the server replaces it instead of leaving another copy. const draftUidRef = useRef(0); // Set once the writer has discarded the message. An autosave already on its // way when that happens would otherwise land afterwards and bring the draft // back, so every save checks this before and after it talks to the server. const discardedRef = useRef(false); // The send waiting out its ten seconds, so Undo can stop it and leaving the // screen cannot lose it. const pendingSend = useRef<{ timer: ReturnType; html: string } | null>(null); const sendRef = useRef<(html: string) => Promise>(async () => undefined); const unmounted = useRef(false); const editorRef = useRef(null); // The body, mirrored into React state. // // It used to live only inside the contenteditable, read through the ref at // save time. Nothing observed it, so the autosave effect - which depends on // what it reads - never re-ran when the message itself was typed. The draft // was written once, moments after the address was entered, and never again: // that is why a draft left behind by a sent message was empty. const [body, setBody] = useState(""); // Leaving with a send still counting down sends it now. Dropping it would lose // a message the writer was told was on its way; Undo is the way to cancel. useEffect(() => { unmounted.current = false; return () => { unmounted.current = true; const pending = pendingSend.current; if (pending) { clearInterval(pending.timer); pendingSend.current = null; void sendRef.current(pending.html); } }; }, []); // An inline reply is opened to be written in, so the cursor starts in it. useEffect(() => { if (inline) editorRef.current?.focus(); }, [inline]); useEffect(() => { const savedSig = localStorage.getItem("lm_user_signature"); // signatureToHtml, not the raw value: the signature may be the plain text // of the old field, whose newlines are whitespace once assigned as HTML - // which is why every line break in a signature used to vanish here. const signatureHtml = signatureToHtml(savedSig ?? ""); if (editorRef.current && signatureHtml) { const initial = `

--
${signatureHtml}`; editorRef.current.innerHTML = initial; setBody(initial); } }, []); // Reopening a draft loads what was written into the composer, and adopts its // UID so carrying on writing replaces that draft instead of leaving the old // copy behind and starting a second one. // // Opening a draft used to land in the reader, which offered to reply to the // half-written message and gave no way to finish it. const draftParam = params?.draft; useEffect(() => { const uid = Number(draftParam); if (!Number.isFinite(uid) || uid <= 0) return; let cancelled = false; void (async () => { try { const res = await fetch(`/api/v1/mail/message/${uid}?folder=drafts`, { cache: "no-store" }); if (!res.ok || cancelled) return; const message = await res.json(); if (cancelled) return; draftUidRef.current = uid; setTo(message.to ?? []); setCc(message.cc ?? []); setImportant(Boolean(message.important)); setInReplyTo(message.in_reply_to ?? ""); if ((message.cc ?? []).length > 0) setShowCcBcc(true); setSubject(message.subject ?? ""); const html = message.html || message.text || ""; if (editorRef.current) editorRef.current.innerHTML = html; setBody(html); } catch { // A draft that cannot be loaded leaves an empty composer rather than // a broken screen; the draft itself is untouched on the server. } })(); return () => { cancelled = true; }; }, [draftParam]); // Removes a stored draft for good. discard_draft is what keeps it out of // Trash: an ordinary delete moves it there, and a draft sitting in Trash is // not discarded. const removeDraft = async (uid: number): Promise => { try { const res = await fetch("/api/v1/mail/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ folder: "drafts", uid, discard_draft: true }), }); return res.ok; } catch { return false; } }; // Saves the draft for real. This used to be a setTimeout that set the words // "draft saved automatically" and nothing else - no request, no storage - so // closing the tab lost the message while the screen had just said otherwise. const saveDraft = useCallback(async () => { if (discardedRef.current) return; const html = editorRef.current?.innerHTML ?? body; if (to.length === 0 && !subject && !body.trim() && !html.trim()) return; setDraftStatus(t("mail.draftSaving")); try { const res = await fetch("/api/v1/mail/draft", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ to, cc, bcc, subject, html, important, in_reply_to: inReplyTo, replace_uid: draftUidRef.current, }), }); if (res.ok) { const data = await res.json().catch(() => ({})); if (typeof data.uid === "number") draftUidRef.current = data.uid; // Discarded while this save was in flight: what it just stored is a // draft nobody wants, so it goes too. if (discardedRef.current && draftUidRef.current > 0) { void removeDraft(draftUidRef.current); return; } } setDraftStatus(res.ok ? t("mail.draftSaved") : t("mail.draftSaveFailed")); } catch { setDraftStatus(t("mail.draftSaveFailed")); } }, [to, cc, bcc, subject, body, important, inReplyTo, t]); // Debounced so a draft is written after a pause in typing rather than on // every keystroke. Skipped once the message is on its way out. useEffect(() => { if (sending) return; // The body alone is enough: a message typed before its recipient is still // worth keeping, and requiring an address here is half of why drafts were // being stored with nothing in them. if (to.length === 0 && !subject && !body.trim()) return; const timer = setTimeout(() => void saveDraft(), 2500); return () => clearTimeout(timer); }, [to, subject, cc, bcc, body, important, sending, saveDraft]); // Throws the message away: stops the autosave, removes the draft already // stored for it, and only then leaves. Leaving first was the old behaviour - // a link to the inbox - and left the draft in Drafts, as if nothing had // been discarded. const discardMessage = async () => { discardedRef.current = true; setDiscarding(true); setSendError(null); const uid = draftUidRef.current; if (uid > 0 && !(await removeDraft(uid))) { // Still there, so say so and let the autosave carry on rather than // leaving the page with a draft the writer thinks is gone. discardedRef.current = false; setDiscarding(false); setSendError(t("mail.discardFailed")); return; } draftUidRef.current = 0; notifyMailStateChanged(); onDiscarded(); }; const executeFormat = (command: string, value: string | undefined = undefined) => { document.execCommand(command, false, value); editorRef.current?.focus(); // execCommand edits the DOM directly, so the mirrored body is re-read // here rather than relying on an input event that may not be dispatched. setBody(editorRef.current?.innerHTML ?? ""); }; const handleFileUpload = (e: React.ChangeEvent) => { if (!e.target.files) return; const newFiles: AttachedFile[] = Array.from(e.target.files).map((f) => ({ id: Math.random().toString(36).substring(2, 9), name: f.name, size: f.size, type: f.type || "application/octet-stream", })); setAttachments((prev) => [...prev, ...newFiles]); }; const removeAttachment = (id: string) => { setAttachments((prev) => prev.filter((a) => a.id !== id)); }; // Anything in a recipient field that is not an address. These stay on screen // as red chips; sending with one would queue mail to a destination that // cannot exist and report success. const invalidRecipients = [...to, ...cc, ...bcc].filter((entry) => !parseAddress(entry).valid); const hasRecipient = to.length + cc.length + bcc.length > 0; const handleSend = async (e: React.FormEvent) => { e.preventDefault(); if (!hasRecipient) { setSendError(t("mail.noRecipients")); return; } if (invalidRecipients.length > 0) { setSendError(t("mail.fixRecipients")); setShowCcBcc(true); return; } setSendError(null); setSending(true); setUndoSeconds(10); const bodyHtml = editorRef.current?.innerHTML || body; // The countdown is counted here and not inside a state updater. The send // used to be a side effect of that updater, which React may run twice (the // message went out twice) and which Undo could not stop: Undo only cleared // the number, and the next tick - finding it empty - sent the message // anyway. let remaining = 10; const timer = setInterval(() => { remaining -= 1; if (remaining > 0) { setUndoSeconds(remaining); return; } clearInterval(timer); pendingSend.current = null; setUndoSeconds(null); void sendRef.current(bodyHtml); }, 1000); pendingSend.current = { timer, html: bodyHtml }; }; const sendActualMessage = async (htmlBody: string) => { try { const res = await fetch("/api/v1/mail/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ to, cc, bcc, subject, html: htmlBody, important, in_reply_to: inReplyTo, // The draft this message grew out of, so the server can discard it. // Without this the draft outlived the message it became and sat in // Drafts as a duplicate of mail already sent. draft_uid: draftUidRef.current, }), }); if (!res.ok) { // Reporting success on a refused send is how a message silently // disappears: the composer used to navigate away regardless. const data = await res.json().catch(() => ({})); setSendError(data.message ?? t("errors.serverError")); setSending(false); return; } // A flush after leaving must not drag the writer back to Sent. if (!unmounted.current) onSent(); } catch { setSendError(t("errors.serverError")); setSending(false); } }; sendRef.current = sendActualMessage; const handleUndo = () => { // Stops the timer itself: clearing the number on screen is not enough. if (pendingSend.current) { clearInterval(pendingSend.current.timer); pendingSend.current = null; } setUndoSeconds(null); setSending(false); }; /** One row of the compose sheet: a fixed-width label beside the control. */ const fieldRow = "flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2.5 shadow-[inset_0_-1px_0_0_rgb(var(--ink)/0.07)]"; const fieldLabel = "w-14 flex-none text-[12.5px] leading-snug text-slate-400"; const bareInput = "min-h-[30px] min-w-[160px] flex-1 border-0 bg-transparent text-sm text-slate-100 placeholder-slate-500 focus:outline-none"; const toolButton = "flex h-8 w-8 items-center justify-center rounded-lg text-slate-300 transition-colors hover:bg-ink/[0.07] hover:text-slate-100"; return (
{!inline &&

{t("mail.compose")}

} {draftStatus && (
{draftStatus}
)}
{sendError && (
{sendError}
)} {undoSeconds !== null && (
{/* The countdown is a placeholder inside the message, not text bolted on after it. It used to be both: the bundle's own "{seconds}" was never substituted and a second, hardcoded count was appended, so this read "Undo Send ({seconds}s remaining) (9s remaining)". */} {t("mail.undoSend", { seconds: undoSeconds })}
{/* type="button": inside this form a button submits it, so Undo used to stop the countdown and then send the message again. */}
)} {/* One sheet, the fields stacked as rows inside it, rather than a stack of separately outlined inputs: the message is one object. */}
!parseAddress(e).valid)} />
{showCcBcc && ( <>
!parseAddress(e).valid)} />
!parseAddress(e).valid)} />
)} {!inline && (
setSubject(e.target.value)} placeholder={t("ui.subject")} required disabled={sending} className={bareInput} />
)} {/* Formatting Toolbar */}
setBody((e.target as HTMLDivElement).innerHTML)} onBlur={(e) => setBody((e.target as HTMLDivElement).innerHTML)} aria-label={t("ui.messageBody")} role="textbox" aria-multiline="true" className={`${inline ? "min-h-[150px]" : "min-h-[280px]"} px-[22px] py-5 font-sans text-[14.5px] leading-relaxed text-slate-200 focus:outline-none`} /> {/* Attachment Selector & Display */}
{attachments.map((file) => (
{file.name} {(file.size / 1024).toFixed(1)} KB
))} {/* Says so plainly rather than letting someone attach a contract and watch it silently not arrive: /mail/send is handed the file names only, because there is no upload endpoint behind this picker yet. */} {attachments.length > 0 && ( {t("mail.attachmentsUnsupported")} )}
); }