Files
webmail-client/frontend/src/components/compose/Composer.tsx
T

1169 lines
44 KiB
TypeScript
Raw Normal View History

2026-07-17 23:20:34 +02:00
import { useQuery } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
2026-07-17 23:20:34 +02:00
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";
2026-07-17 23:20:34 +02:00
import type { AccountOut, AttachmentOut, DraftInput, DraftOut } from "../../lib/types";
import { showOfflineNotice } from "../../lib/offlineNotice";
2026-07-17 23:20:34 +02:00
import { RecipientChips } from "./RecipientChips";
import { RichTextEditor, type RichTextEditorHandle } from "./RichTextEditor";
import { hasUndoHost, offerUndo } from "./undoSend";
2026-07-17 23:20:34 +02:00
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;
2026-07-17 23:20:34 +02:00
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 (a forward: its header
// block, one line each) sits above the
2026-07-17 23:20:34 +02:00
// quoted message body (fetched from the original message's sanitized HTML).
quote?: { attribution: string; messageId: string };
// A forward: the message whose attachments go along with it.
forwardFrom?: 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[];
2026-07-17 23:20:34 +02:00
}
// 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,
};
}
2026-07-17 23:20:34 +02:00
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function textToHtml(text: string): string {
return text
.split("\n")
.map((line) => `<div>${escapeHtml(line) || "<br>"}</div>`)
.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 = "",
2026-07-17 23:20:34 +02:00
children,
}: {
onClick?: () => void;
title: string;
active?: boolean;
className?: string;
2026-07-17 23:20:34 +02:00
children: React.ReactNode;
}) {
return (
<button
type="button"
title={title}
aria-label={title}
onClick={onClick}
className={`${className} flex items-center gap-1 rounded-md px-2 py-1.5 text-sm ${
2026-07-17 23:20:34 +02:00
active ? "bg-app-active text-app-fg" : "text-app-muted hover:bg-app-hover hover:text-app-fg"
}`}
>
{children}
</button>
);
}
// 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]";
2026-07-17 23:20:34 +02:00
const caret = (
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 6l4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
export function Composer({
init,
onClose,
expanded = false,
onToggleExpand,
minimized = false,
onMinimize,
onTitle,
fitContainer = false,
2026-07-17 23:20:34 +02:00
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;
2026-07-17 23:20:34 +02:00
fullWindow?: boolean;
}) {
// Whether something other than the content decides how tall this is.
const sized = fullWindow || fitContainer;
2026-07-17 23:20:34 +02:00
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));
2026-07-17 23:20:34 +02:00
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<string | null>(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<HTMLDivElement>(null);
const fromRef = useRef<HTMLDivElement>(null);
const [attachments, setAttachments] = useState<AttachmentOut[]>(init.attachments ?? []);
// Files attached while offline, kept on this device until they can be uploaded.
const [localFiles, setLocalFiles] = useState<LocalFile[]>(init.localFiles ?? []);
2026-07-17 23:20:34 +02:00
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]);
2026-07-17 23:20:34 +02:00
const draftId = useRef<string | null>(init.draftId ?? null);
// This message's copy on this device, while there is one (lib/scheduled.ts).
const localId = useRef<string | null>(init.localId ?? null);
const discardedRef = useRef(false);
// Kept as what it was opened as: a scheduled message edited offline stays scheduled.
const localState = useRef<LocalState>(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;
};
2026-07-17 23:20:34 +02:00
const undoTimer = useRef<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const editorRef = useRef<RichTextEditorHandle>(null);
2026-07-17 23:20:34 +02:00
// 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]);
2026-07-17 23:20:34 +02:00
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]);
2026-07-17 23:20:34 +02:00
// 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}`);
2026-07-17 23:20:34 +02:00
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;
2026-07-17 23:20:34 +02:00
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
}, []);
// A forward carries the original's attachments (shared on the server, not
// re-uploaded). Once per window: StrictMode runs effects twice in dev, so a
// ref — not a cancel flag — keeps it from attaching them twice.
const forwardedFrom = useRef(false);
useEffect(() => {
const source = init.forwardFrom;
if (!source || init.draftId || forwardedFrom.current) return;
forwardedFrom.current = true;
void api
.forwardAttachments(source)
.then((rows) => {
if (rows.length) setAttachments((prev) => [...rows, ...prev]);
})
.catch((e) => {
if (!isOfflineError(e)) setAttachError(t("compose.forwardAttachFailed"));
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const addFiles = async (files: FileList | File[]) => {
markDirty();
2026-07-17 23:20:34 +02:00
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;
}
2026-07-17 23:20:34 +02:00
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();
2026-07-17 23:20:34 +02:00
setAttachments((prev) => prev.filter((a) => a.id !== id));
};
2026-07-17 23:20:34 +02:00
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<HTMLDivElement>(null);
const [dragKind, setDragKind] = useState<null | "body" | "attach">(null);
const readDataUrl = (file: File): Promise<string> =>
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(`<img src="${await readDataUrl(file)}" style="max-width:100%">`);
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 = (() => {
2026-07-17 23:20:34 +02:00
if (init.html) return init.html;
if (init.text) return textToHtml(init.text);
if (init.quote) {
return (
`<div><br></div>` +
`<div class="wmc-quoted">` +
// One line for a reply; the header block of a forward is several.
`<div>${escapeHtml(init.quote.attribution).replace(/\n/g, "<br>")}</div>` +
`<blockquote>${quoteHtml}</blockquote></div>`
2026-07-17 23:20:34 +02:00
);
}
return `<div><br></div><div><br></div><div class="wmc-signature">${signature}</div>`;
})();
2026-07-17 23:20:34 +02:00
const bodyReady = quoteResolved && accountsQuery.data !== undefined;
2026-07-17 23:20:34 +02:00
const payload = (atts: AttachmentOut[] = attachments): DraftInput => {
2026-07-17 23:20:34 +02:00
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),
2026-07-17 23:20:34 +02:00
},
};
};
// 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<AttachmentOut[]> => {
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;
}
2026-07-17 23:20:34 +02:00
try {
await saveLocal(localState.current);
setHeldId(localId.current);
setStatus("savedLocal");
2026-07-17 23:20:34 +02:00
} 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();
2026-07-17 23:20:34 +02:00
}, 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;
2026-07-17 23:20:34 +02:00
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(() => {});
}
2026-07-17 23:20:34 +02:00
},
[],
);
// 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");
}
};
2026-07-17 23:20:34 +02:00
const send = async () => {
setStatus("sending");
sentRef.current = true; // don't also mirror to Drafts on close
if (isOffline()) {
await schedule();
return;
}
2026-07-17 23:20:34 +02:00
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);
2026-07-17 23:20:34 +02:00
} catch (e) {
if (isOfflineError(e)) {
await schedule();
return;
}
sentRef.current = false;
2026-07-17 23:20:34 +02:00
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
2026-07-17 23:20:34 +02:00
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()) {
2026-07-17 23:20:34 +02:00
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
};
2026-07-17 23:20:34 +02:00
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 (
<div className="m-3 flex items-center justify-between rounded-lg bg-app-fg px-4 py-2.5 text-sm text-app-surface shadow-lg">
<span>{t("compose.sending")}</span>
<span className="flex items-center gap-1.5">
{undoLeft > 0 && (
<span className="tabular-nums text-app-surface/70">
{t("compose.undoIn", { count: undoLeft })}
</span>
)}
<button className="font-medium text-app-accent hover:underline" onClick={doUndo}>
{t("compose.undo")}
</button>
</span>
2026-07-17 23:20:34 +02:00
</div>
);
}
return (
<div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={`relative flex flex-col overflow-hidden border-app-border bg-app-elevated ${
2026-07-17 23:20:34 +02:00
fullWindow
? "h-full"
: minimized
? "rounded-t-xl border-x border-t shadow-2xl"
: `rounded-xl border shadow-2xl ${sized ? "h-full" : ""}`
2026-07-17 23:20:34 +02:00
}`}
>
{dragKind && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center border-2 border-dashed border-app-accent bg-app-accent/10 text-sm font-medium text-app-accent">
{dragKind === "body" ? t("compose.dropBody") : t("compose.dropAttach")}
</div>
)}
{showClosePrompt && (
<div
className="absolute inset-0 z-40 flex items-center justify-center bg-black/40 p-4"
onClick={() => setShowClosePrompt(false)}
>
<div
className="w-72 rounded-lg border border-app-border bg-app-elevated p-4 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<p className="mb-4 text-sm text-app-fg">{t("compose.closePrompt")}</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setShowClosePrompt(false);
void discard();
}}
className="rounded-md px-3 py-1.5 text-sm text-red-500 hover:bg-app-hover"
>
{t("compose.discard")}
</button>
<button
type="button"
onClick={() => void saveDraftAndClose()}
className="rounded-md bg-app-accent px-3 py-1.5 text-sm font-medium text-app-accent-fg hover:opacity-90"
>
{t("compose.saveDraft")}
</button>
</div>
</div>
</div>
)}
2026-07-17 23:20:34 +02:00
{/* Header: Send · Attach · formatting toggle · More · window controls */}
<div className="flex items-center gap-1.5 border-b border-app-border bg-app-surface px-3 py-2">
{minimized ? (
// Collapsed: the subject stands in for the action buttons, and the whole
// bar is the restore target.
<button
type="button"
onClick={onMinimize}
title={t("compose.restore")}
className="min-w-0 flex-1 truncate py-1.5 text-left text-sm font-medium text-app-fg"
>
{subject.trim() || t("compose.newMessage")}
</button>
) : (
<>
<button
type="button"
onClick={send}
disabled={status === "sending" || (!to && !cc)}
className="flex items-center gap-2 rounded-md bg-app-accent px-3 py-1.5 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" className={PHONE_ICON}>
<path d="M14.5 1.5L7.2 8.8" />
<path d="M14.5 1.5L9.8 14.5 7.2 8.8 1.5 6.2 14.5 1.5z" />
</svg>
{/* Phone width: the icon alone. */}
<span className="max-md:hidden">
{status === "sending" ? t("compose.sending") : offline ? t("compose.sendLater") : t("compose.send")}
</span>
</button>
<HeaderButton onClick={() => fileInputRef.current?.click()} title={t("compose.attach")}>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" className={PHONE_ICON}>
<path d="M12.5 6.5l-5.2 5.2a2.5 2.5 0 0 1-3.5-3.5l5.4-5.4a1.6 1.6 0 0 1 2.3 2.3l-5.3 5.3a0.8 0.8 0 0 1-1.1-1.1l4.9-4.9" />
</svg>
2026-09-28 08:09:09 +02:00
{/* Phone width: the paperclip alone, so the close button still fits. */}
<span className="max-md:hidden">{t("compose.attach")}</span>
2026-07-17 23:20:34 +02:00
</HeaderButton>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files && e.target.files.length) void addFiles(e.target.files);
e.target.value = "";
}}
/>
<HeaderButton
onClick={() => setShowToolbar((v) => !v)}
title={t("compose.fmtBold")}
active={showToolbar}
>
<span className="text-sm font-semibold underline">A</span>
</HeaderButton>
<div className="relative" ref={moreRef}>
<HeaderButton onClick={() => setMoreOpen((v) => !v)} title={t("compose.more")}>
{t("compose.more")}
</HeaderButton>
{moreOpen && (
<div className="absolute left-0 top-full z-30 mt-1 w-56 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 text-sm shadow-xl">
<button
className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
onClick={() => {
setMoreOpen(false);
setShowCc(true);
}}
>
{t("compose.cc")} / {t("compose.bcc")}
</button>
<button
className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover disabled:opacity-40"
disabled={!signature}
onClick={() => {
setMoreOpen(false);
editorRef.current?.insertSignature(signature);
}}
>
{t("compose.insertSignature")}
</button>
{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 (
<>
<button
className="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
aria-expanded={spellMenuOpen}
onClick={() => setSpellMenuOpen((v) => !v)}
>
<span className="truncate">
{t("compose.spellMenu")}: {nameOf(current)}
</span>
<span className="shrink-0 text-app-faint">{spellMenuOpen ? "▾" : "›"}</span>
</button>
{spellMenuOpen && (
<div className="max-h-56 overflow-y-auto border-y border-app-border bg-app-surface py-1">
{choices.map((c) => (
<button
key={c.code}
className="flex w-full items-center gap-2 px-3 py-1 text-left text-[13px] text-app-fg hover:bg-app-hover"
onClick={() => {
setSpellLang(c.code);
setSpellMenuOpen(false);
setMoreOpen(false);
}}
>
<span className="w-3 shrink-0 text-app-accent">{c.code === current ? "✓" : ""}</span>
<span className="truncate">{c.name}</span>
</button>
))}
</div>
)}
</>
);
})()}
<button
className="block w-full px-3 py-1.5 text-left text-red-500 hover:bg-app-hover"
onClick={() => {
setMoreOpen(false);
void discard();
}}
>
{t("compose.discard")}
</button>
</div>
)}
</div>
</>
)}
2026-07-17 23:20:34 +02:00
<div className="ml-auto flex items-center gap-1 text-xs text-app-faint">
{!minimized && status === "saving" && <span className="mr-1">{t("compose.savingDraft")}</span>}
{!minimized && status === "saved" && <span className="mr-1">{t("compose.draftSaved")}</span>}
{!minimized && status === "savedLocal" && <span className="mr-1">{t("compose.savedOnDevice")}</span>}
{!minimized && status === "error" && (
<span className="mr-1 text-red-500">{t("compose.sendFailed")}</span>
)}
{!fullWindow && onMinimize && (
<HeaderButton
onClick={onMinimize}
title={minimized ? t("compose.restore") : t("compose.minimize")}
>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
{minimized ? (
<path d="M4 10l4-4 4 4" strokeLinejoin="round" />
) : (
<path d="M3 12h10" />
)}
</svg>
</HeaderButton>
)}
2026-07-17 23:20:34 +02:00
{!fullWindow && onToggleExpand && (
<HeaderButton
onClick={onToggleExpand}
title={expanded ? t("compose.collapse") : t("compose.expand")}
active={expanded}
>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
{expanded ? (
<path d="M2 6h4V2M14 6h-4V2M2 10h4v4M14 10h-4v4" />
) : (
<path d="M6 2H2v4M10 2h4v4M6 14H2v-4M10 14h4v-4" />
)}
2026-07-17 23:20:34 +02:00
</svg>
</HeaderButton>
)}
{!fullWindow && (
2026-09-28 08:09:09 +02:00
// No second browser window to pop out into on a phone.
<div className="contents max-md:hidden">
<HeaderButton onClick={popOut} title={t("compose.popOut")} className="wm-online-only">
2026-07-17 23:20:34 +02:00
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 3H3v10h10v-3M9 3h4v4M13 3L7 9" />
</svg>
</HeaderButton>
2026-09-28 08:09:09 +02:00
</div>
2026-07-17 23:20:34 +02:00
)}
<HeaderButton onClick={requestClose} title={t("common.close")}>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className={PHONE_ICON}>
2026-07-17 23:20:34 +02:00
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</HeaderButton>
</div>
</div>
{/* Hidden rather than unmounted while collapsed: the editor seeds its
contentEditable once, so dropping this subtree would lose the body. */}
<div className={minimized ? "hidden" : "contents"}>
{/* From */}
<div
className="relative flex items-center gap-2 border-b border-app-border px-3 py-2"
ref={fromRef}
2026-07-17 23:20:34 +02:00
>
<span className="w-10 shrink-0 text-xs font-semibold text-app-fg">{t("compose.from")}</span>
<button
type="button"
onClick={() => accounts.length > 1 && setFromOpen((v) => !v)}
className="flex items-center gap-1.5 rounded px-1 py-0.5 text-sm hover:bg-app-hover"
>
{fromName && <span className="font-medium text-app-accent">{fromName}</span>}
<span className="text-app-muted">{fromName ? `<${fromEmail}>` : fromEmail}</span>
{accounts.length > 1 && <span className="text-app-faint">{caret}</span>}
</button>
{fromOpen && (
<div className="absolute left-12 top-full z-30 mt-1 w-80 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 shadow-xl">
{accounts.map((a) => (
<button
key={a.id}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-app-hover"
onClick={() => {
markDirty();
setAccountId(a.id);
setFromOpen(false);
}}
>
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: a.color }} />
{a.display_name && <span className="font-medium text-app-fg">{a.display_name}</span>}
<span className="text-app-muted">{a.email}</span>
</button>
))}
</div>
)}
</div>
2026-07-17 23:20:34 +02:00
{/* Recipients */}
<RecipientChips
label={t("compose.to")}
value={to}
onChange={(v) => {
markDirty();
setTo(v);
}}
autoFocus={init.kind === "new" || init.kind === "forward"}
rightSlot={
!showCc ? (
2026-07-17 23:20:34 +02:00
<button
type="button"
className="text-xs text-app-muted hover:text-app-fg"
onClick={() => setShowCc(true)}
2026-07-17 23:20:34 +02:00
>
{t("compose.cc")} {t("compose.bcc")}
2026-07-17 23:20:34 +02:00
</button>
) : undefined
}
/>
{showCc && (
<>
<RecipientChips
label={t("compose.cc")}
value={cc}
onChange={(v) => {
markDirty();
setCc(v);
}}
/>
<RecipientChips
label={t("compose.bcc")}
value={bcc}
onChange={(v) => {
markDirty();
setBcc(v);
}}
/>
</>
)}
<input
className="border-b border-app-border bg-transparent px-3 py-2 text-sm text-app-fg outline-none placeholder:text-app-faint"
placeholder={t("compose.subject")}
value={subject}
onChange={(e) => {
markDirty();
setSubject(e.target.value);
}}
/>
{(attachments.length > 0 || localFiles.length > 0 || attachError) && (
<div className="flex flex-wrap items-center gap-2 border-b border-app-border px-3 py-2">
{attachments.map((att) => (
<span
key={att.id}
className="flex items-center gap-1.5 rounded border border-app-border bg-app-hover px-2 py-1 text-xs text-app-fg"
>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.5 6.5l-5.2 5.2a2.5 2.5 0 0 1-3.5-3.5l5.4-5.4a1.6 1.6 0 0 1 2.3 2.3l-5.3 5.3a0.8 0.8 0 0 1-1.1-1.1l4.9-4.9" />
</svg>
<span className="max-w-[12rem] truncate">{att.filename}</span>
<span className="text-app-faint">{formatBytes(att.size_bytes)}</span>
<button
type="button"
onClick={() => removeAttachment(att.id)}
className="text-app-faint hover:text-app-fg"
aria-label={`remove ${att.filename}`}
>
✕
</button>
</span>
))}
{localFiles.map((f) => (
<span
key={f.id}
title={t("compose.attachOnDevice")}
className="flex items-center gap-1.5 rounded border border-dashed border-app-border bg-app-hover px-2 py-1 text-xs text-app-fg"
>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.5 6.5l-5.2 5.2a2.5 2.5 0 0 1-3.5-3.5l5.4-5.4a1.6 1.6 0 0 1 2.3 2.3l-5.3 5.3a0.8 0.8 0 0 1-1.1-1.1l4.9-4.9" />
</svg>
<span className="max-w-[12rem] truncate">{f.name}</span>
<span className="text-app-faint">{formatBytes(f.size)}</span>
<button
type="button"
onClick={() => removeLocalFile(f.id)}
className="text-app-faint hover:text-app-fg"
aria-label={`remove ${f.name}`}
>
✕
</button>
</span>
))}
{attachError && <span className="text-xs text-red-500">{attachError}</span>}
</div>
)}
{/* Body — wrapped so a drop here is detected as the writing area. */}
<div ref={editorAreaRef} className="flex min-h-0 flex-1 flex-col">
{bodyReady ? (
<RichTextEditor
ref={editorRef}
initialHtml={initialHtml}
// A new reply: type straight away, above the quoted original.
focusOnStart={Boolean(init.quote) && !init.html && init.kind !== "forward"}
onInput={(html) => {
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}
/>
) : (
<div className="min-h-[250px] flex-1 px-3 py-3 text-sm text-app-faint">…</div>
)}
</div>
</div>
2026-07-17 23:20:34 +02:00
</div>
);
}