2026-07-17 23:20:34 +02:00
|
|
|
|
import { useQuery } from "@tanstack/react-query";
|
2026-07-18 15:27:09 +02:00
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
|
|
|
|
|
import { useT } from "../../i18n";
|
|
|
|
|
|
import { api, ApiError } from "../../lib/api";
|
|
|
|
|
|
import type { AccountOut, AttachmentOut, DraftInput, DraftOut } from "../../lib/types";
|
|
|
|
|
|
import { Icon } from "../Icon";
|
|
|
|
|
|
import { RecipientChips } from "./RecipientChips";
|
2026-07-18 15:27:09 +02:00
|
|
|
|
import { RichTextEditor, type RichTextEditorHandle } from "./RichTextEditor";
|
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;
|
|
|
|
|
|
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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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, "<").replace(/>/g, ">");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
children,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
onClick?: () => void;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
active?: boolean;
|
|
|
|
|
|
children: React.ReactNode;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
title={title}
|
|
|
|
|
|
aria-label={title}
|
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
fullWindow = false,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
init: ComposerInit;
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
expanded?: boolean;
|
|
|
|
|
|
onToggleExpand?: () => void;
|
|
|
|
|
|
fullWindow?: boolean;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
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("");
|
|
|
|
|
|
const [showCc, setShowCc] = useState(Boolean(init.cc));
|
|
|
|
|
|
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);
|
2026-07-20 23:12:25 +02:00
|
|
|
|
const moreRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
const fromRef = useRef<HTMLDivElement>(null);
|
2026-07-17 23:20:34 +02:00
|
|
|
|
const [attachments, setAttachments] = useState<AttachmentOut[]>([]);
|
|
|
|
|
|
const [attachError, setAttachError] = useState("");
|
|
|
|
|
|
const [status, setStatus] = useState<"" | "saving" | "saved" | "sending" | "error">("");
|
2026-07-26 16:15:49 +02:00
|
|
|
|
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]);
|
2026-07-20 23:12:25 +02:00
|
|
|
|
const [showClosePrompt, setShowClosePrompt] = useState(false);
|
2026-07-17 23:20:34 +02:00
|
|
|
|
const draftId = useRef<string | null>(init.draftId ?? null);
|
2026-07-18 15:27:09 +02:00
|
|
|
|
const sentRef = useRef(false); // send initiated -> don't mirror to Drafts on close
|
2026-07-20 23:12:25 +02:00
|
|
|
|
// 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);
|
2026-07-18 15:27:09 +02:00
|
|
|
|
const editorRef = useRef<RichTextEditorHandle>(null);
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
2026-07-20 23:12:25 +02:00
|
|
|
|
// Close the From / More dropdowns on any click outside them.
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!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]);
|
|
|
|
|
|
|
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 ?? "";
|
2026-07-18 15:27:09 +02:00
|
|
|
|
const signature = account?.signature ?? "";
|
|
|
|
|
|
|
|
|
|
|
|
// When the From account changes, swap the signature block for the new one.
|
|
|
|
|
|
const firstAccount = useRef(true);
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (firstAccount.current) {
|
|
|
|
|
|
firstAccount.current = false;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
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" });
|
|
|
|
|
|
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) 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
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-07-26 16:15:49 +02:00
|
|
|
|
const addFiles = async (files: FileList | File[]) => {
|
2026-07-20 23:12:25 +02:00
|
|
|
|
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 {
|
|
|
|
|
|
const att = await api.uploadComposeAttachment(file);
|
|
|
|
|
|
setAttachments((prev) => [...prev, att]);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setAttachError(e instanceof ApiError ? e.message : t("compose.attachTooLarge"));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-20 23:12:25 +02:00
|
|
|
|
const removeAttachment = (id: string) => {
|
|
|
|
|
|
markDirty();
|
2026-07-17 23:20:34 +02:00
|
|
|
|
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
2026-07-20 23:12:25 +02:00
|
|
|
|
};
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
2026-07-26 16:15:49 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-18 15:27:09 +02:00
|
|
|
|
// 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>` +
|
2026-07-18 15:27:09 +02:00
|
|
|
|
`<div class="wmc-quoted">` +
|
2026-07-17 23:20:34 +02:00
|
|
|
|
`<div>${escapeHtml(init.quote.attribution)}</div>` +
|
2026-07-18 15:27:09 +02:00
|
|
|
|
`<blockquote>${quoteHtml}</blockquote></div>`
|
2026-07-17 23:20:34 +02:00
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-07-18 15:27:09 +02:00
|
|
|
|
return `<div><br></div><div><br></div><div class="wmc-signature">${signature}</div>`;
|
|
|
|
|
|
})();
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
2026-07-18 15:27:09 +02:00
|
|
|
|
const bodyReady = quoteResolved && accountsQuery.data !== undefined;
|
2026-07-17 23:20:34 +02:00
|
|
|
|
|
|
|
|
|
|
const payload = (): 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: attachments.map((a) => a.id),
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (undo) return;
|
|
|
|
|
|
const handle = window.setTimeout(async () => {
|
2026-07-20 23:12:25 +02:00
|
|
|
|
if (!dirtyRef.current) return; // nothing the user changed -> don't touch the draft
|
2026-07-17 23:20:34 +02:00
|
|
|
|
if (!to && !subject && !bodyHtml) return;
|
|
|
|
|
|
setStatus("saving");
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (draftId.current) {
|
|
|
|
|
|
await api.updateDraft(draftId.current, payload());
|
|
|
|
|
|
} else {
|
|
|
|
|
|
draftId.current = (await api.createDraft(payload())).id;
|
|
|
|
|
|
}
|
|
|
|
|
|
setStatus("saved");
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setStatus("error");
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 2000);
|
|
|
|
|
|
return () => window.clearTimeout(handle);
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, [accountId, to, cc, bcc, subject, bodyHtml, attachments]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(
|
|
|
|
|
|
() => () => {
|
|
|
|
|
|
if (undoTimer.current) window.clearTimeout(undoTimer.current);
|
2026-07-18 15:27:09 +02:00
|
|
|
|
// On close, mirror the draft to the IMAP Drafts folder so it can be
|
2026-07-20 23:12:25 +02:00
|
|
|
|
// 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 (draftId.current && !sentRef.current && dirtyRef.current) {
|
2026-07-18 15:27:09 +02:00
|
|
|
|
void api.publishDraft(draftId.current).catch(() => {});
|
|
|
|
|
|
}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
},
|
|
|
|
|
|
[],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const send = async () => {
|
|
|
|
|
|
setStatus("sending");
|
2026-07-18 15:27:09 +02:00
|
|
|
|
sentRef.current = true; // don't also mirror to Drafts on close
|
2026-07-17 23:20:34 +02:00
|
|
|
|
try {
|
|
|
|
|
|
if (!draftId.current) draftId.current = (await api.createDraft(payload())).id;
|
|
|
|
|
|
else await api.updateDraft(draftId.current, payload());
|
|
|
|
|
|
const res = await api.send(draftId.current);
|
2026-07-26 16:15:49 +02:00
|
|
|
|
// Drive the countdown + auto-close off the server's undo deadline.
|
|
|
|
|
|
const until = new Date(res.undo_until).getTime();
|
|
|
|
|
|
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) {
|
2026-07-18 15:27:09 +02:00
|
|
|
|
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);
|
2026-07-18 15:27:09 +02:00
|
|
|
|
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 () => {
|
|
|
|
|
|
if (draftId.current) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.deleteDraft(draftId.current);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* ignore */
|
|
|
|
|
|
}
|
|
|
|
|
|
draftId.current = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
onClose();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-20 23:12:25 +02:00
|
|
|
|
// 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) setShowClosePrompt(true);
|
|
|
|
|
|
else onClose();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const saveDraftAndClose = async () => {
|
|
|
|
|
|
setShowClosePrompt(false);
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (dirtyRef.current) {
|
|
|
|
|
|
if (draftId.current) await api.updateDraft(draftId.current, payload());
|
|
|
|
|
|
else draftId.current = (await api.createDraft(payload())).id;
|
|
|
|
|
|
}
|
|
|
|
|
|
} 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">
|
2026-07-26 16:15:49 +02:00
|
|
|
|
<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
|
2026-07-26 16:15:49 +02:00
|
|
|
|
onDragOver={onDragOver}
|
|
|
|
|
|
onDragLeave={onDragLeave}
|
|
|
|
|
|
onDrop={onDrop}
|
2026-07-20 23:12:25 +02:00
|
|
|
|
className={`relative flex flex-col overflow-hidden border-app-border bg-app-elevated ${
|
2026-07-17 23:20:34 +02:00
|
|
|
|
fullWindow
|
|
|
|
|
|
? "h-full"
|
|
|
|
|
|
: `rounded-xl border shadow-2xl ${expanded ? "h-full" : ""}`
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
2026-07-26 16:15:49 +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>
|
|
|
|
|
|
)}
|
2026-07-20 23:12:25 +02:00
|
|
|
|
{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">
|
|
|
|
|
|
<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">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
{status === "sending" ? t("compose.sending") : t("compose.send")}
|
|
|
|
|
|
</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">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
{t("compose.attach")}
|
|
|
|
|
|
</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>
|
2026-07-20 23:12:25 +02:00
|
|
|
|
<div className="relative" ref={moreRef}>
|
2026-07-17 23:20:34 +02:00
|
|
|
|
<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-44 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>
|
2026-07-18 15:27:09 +02:00
|
|
|
|
<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>
|
2026-07-17 23:20:34 +02:00
|
|
|
|
<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">
|
|
|
|
|
|
{status === "saving" && <span className="mr-1">{t("compose.savingDraft")}</span>}
|
|
|
|
|
|
{status === "saved" && <span className="mr-1">{t("compose.draftSaved")}</span>}
|
|
|
|
|
|
{status === "error" && <span className="mr-1 text-red-500">{t("compose.sendFailed")}</span>}
|
|
|
|
|
|
{!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">
|
|
|
|
|
|
<path d="M6 2H2v4M10 2h4v4M6 14H2v-4M10 14h4v-4" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</HeaderButton>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{!fullWindow && (
|
|
|
|
|
|
<HeaderButton onClick={popOut} title={t("compose.popOut")}>
|
|
|
|
|
|
<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-07-20 23:12:25 +02:00
|
|
|
|
<HeaderButton onClick={requestClose} title={t("common.close")}>
|
2026-07-17 23:20:34 +02:00
|
|
|
|
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
|
|
|
|
|
<path d="M4 4l8 8M12 4l-8 8" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</HeaderButton>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* From */}
|
2026-07-20 23:12:25 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
<div className="ml-auto flex items-center gap-1 text-xs text-app-muted">
|
|
|
|
|
|
<Icon name="reply" size={13} />
|
|
|
|
|
|
{t("compose.replyLabel")}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{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={() => {
|
2026-07-20 23:12:25 +02:00
|
|
|
|
markDirty();
|
2026-07-17 23:20:34 +02:00
|
|
|
|
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}
|
2026-07-20 23:12:25 +02:00
|
|
|
|
onChange={(v) => {
|
|
|
|
|
|
markDirty();
|
|
|
|
|
|
setTo(v);
|
|
|
|
|
|
}}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
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 && (
|
|
|
|
|
|
<>
|
2026-07-20 23:12:25 +02:00
|
|
|
|
<RecipientChips
|
|
|
|
|
|
label={t("compose.cc")}
|
|
|
|
|
|
value={cc}
|
|
|
|
|
|
onChange={(v) => {
|
|
|
|
|
|
markDirty();
|
|
|
|
|
|
setCc(v);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<RecipientChips
|
|
|
|
|
|
label={t("compose.bcc")}
|
|
|
|
|
|
value={bcc}
|
|
|
|
|
|
onChange={(v) => {
|
|
|
|
|
|
markDirty();
|
|
|
|
|
|
setBcc(v);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-07-17 23:20:34 +02:00
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<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}
|
2026-07-20 23:12:25 +02:00
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
markDirty();
|
|
|
|
|
|
setSubject(e.target.value);
|
|
|
|
|
|
}}
|
2026-07-17 23:20:34 +02:00
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{(attachments.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>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{attachError && <span className="text-xs text-red-500">{attachError}</span>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-07-26 16:15:49 +02:00
|
|
|
|
{/* 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}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="min-h-[250px] flex-1 px-3 py-3 text-sm text-app-faint">…</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-07-17 23:20:34 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|