Files
webmail-client/frontend/src/components/compose/Composer.tsx
T
lukasandClaude Opus 5.5 fba3c947bf Attachment previews, forwarded attachments, row dividers
Attachments in the reading pane are tiles with a preview: images and a PDF's
first page, rendered on the server (Pillow, pypdfium2) as WebP of at most
480 px, cached beside the blob store and in the browser. Tiles are all one
size — at most 230 px wide, narrower so three still fit in a row — and frame
each preview by its shape: close to the tile's it fills it, very tall / wide /
square ones show whole over a blurred copy, small ones are not enlarged, PDFs
show the top of the page. The name bar (checkbox, type, name, size, download)
slides in on hover and adapts to the tile's width; files without a preview
get a large type icon. Previews of opened conversations are kept offline.

Forward now carries the original's attachments: new compose attachments
sharing the stored blobs (POST /messages/{id}/forward-attachments).

Mailbox display / Settings → Appearance: "Show a dividing line between
rows" (off by default, per device), with a new switch. Settings drop the
"On a phone…" note, and "Language" reads "Spellchecking language".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:15:17 +02:00

1165 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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[];
}
// 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, "&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 = "",
children,
}: {
onClick?: () => void;
title: string;
active?: boolean;
className?: string;
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 ${
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]";
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,
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<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 ?? []);
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<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;
};
const undoTimer = useRef<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const editorRef = useRef<RichTextEditorHandle>(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
}, []);
// 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();
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<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 = (() => {
if (init.html) return init.html;
if (init.text) return textToHtml(init.text);
if (init.quote) {
return (
`<div><br></div>` +
`<div class="wmc-quoted">` +
`<div>${escapeHtml(init.quote.attribution)}</div>` +
`<blockquote>${quoteHtml}</blockquote></div>`
);
}
return `<div><br></div><div><br></div><div class="wmc-signature">${signature}</div>`;
})();
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<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;
}
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 (
<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>
</div>
);
}
return (
<div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={`relative flex flex-col overflow-hidden border-app-border bg-app-elevated ${
fullWindow
? "h-full"
: minimized
? "rounded-t-xl border-x border-t shadow-2xl"
: `rounded-xl border shadow-2xl ${sized ? "h-full" : ""}`
}`}
>
{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>
)}
{/* 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>
{/* Phone width: the paperclip alone, so the close button still fits. */}
<span className="max-md:hidden">{t("compose.attach")}</span>
</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>
</>
)}
<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>
)}
{!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" />
)}
</svg>
</HeaderButton>
)}
{!fullWindow && (
// 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">
<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>
</div>
)}
<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}>
<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}
>
<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>
{/* Recipients */}
<RecipientChips
label={t("compose.to")}
value={to}
onChange={(v) => {
markDirty();
setTo(v);
}}
autoFocus={init.kind === "new" || init.kind === "forward"}
rightSlot={
!showCc ? (
<button
type="button"
className="text-xs text-app-muted hover:text-app-fg"
onClick={() => setShowCc(true)}
>
{t("compose.cc")} {t("compose.bcc")}
</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}
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>
</div>
);
}