refactor(webmail): the composer is a component, so a reply can be written under a conversation

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-22 16:41:59 -03:00
1 parent 997dd8848f
commit 86a69d1a96
3 files changed
+619 -575

No files matched your search

@@ -1,583 +1,24 @@
"use client";
import React, { useCallback, useEffect, useRef, useState, use } from "react";
import Link from "next/link";
import {
Send,
Paperclip,
Bold,
Italic,
Underline,
Strikethrough,
List,
ListOrdered,
Link as LinkIcon,
Undo2,
X,
Sparkles,
CheckCircle2,
Flag,
} from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Button } from "../../../../components/ui/Button";
import { notifyMailStateChanged } from "../../../../lib/useFolders";
import { signatureToHtml } from "../../../../lib/signature";
import { RecipientInput } from "../../../../components/RecipientInput";
import { parseAddress, parseAddressList, prefixSubject } from "../../../../lib/recipients";
import { use } from "react";
import { Composer, type ComposerInitial } from "../../../../components/Composer";
/** Entries from a query-string list, which may be several addresses. */
function entriesFrom(value: string | undefined): string[] {
return value ? parseAddressList(value).map((p) => p.entry) : [];
}
interface AttachedFile {
id: string;
name: string;
size: number;
type: string;
}
export default function ComposePage({
searchParams,
}: {
searchParams?: Promise<{
replyTo?: string;
cc?: string;
subject?: string;
inReplyTo?: string;
mode?: string;
draft?: string;
}>;
}) {
const t = useTranslations();
/**
* The full compose screen. The composer itself lives in components/Composer so
* the reply written under a conversation is the same code, not a copy of it.
*/
export default function ComposePage({ searchParams }: { searchParams?: Promise<ComposerInitial> }) {
const params = searchParams ? use(searchParams) : {};
// Recipients are lists of entries, not one string to be split later: a
// reply-all arrives with several people already in To and Cc.
const [to, setTo] = useState<string[]>(() => entriesFrom(params?.replyTo));
const [cc, setCc] = useState<string[]>(() => entriesFrom(params?.cc));
const [bcc, setBcc] = useState<string[]>([]);
const [showCcBcc, setShowCcBcc] = useState(() => entriesFrom(params?.cc).length > 0);
const [subject, setSubject] = useState(
params?.subject ? prefixSubject(params.subject, params.mode === "forward" ? "forward" : "reply") : "",
);
// The Message-ID being answered, so the reply threads under it.
const [inReplyTo, setInReplyTo] = useState(params?.mode === "forward" ? "" : params?.inReplyTo || "");
const [important, setImportant] = useState(false);
const [attachments, setAttachments] = useState<AttachedFile[]>([]);
const [sending, setSending] = useState(false);
const [undoSeconds, setUndoSeconds] = useState<number | null>(null);
const [draftStatus, setDraftStatus] = useState<string>("");
const [discarding, setDiscarding] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
// The UID of the draft already stored for this message. Sent back on each
// autosave so the server replaces it instead of leaving another copy.
const draftUidRef = useRef<number>(0);
// Set once the writer has discarded the message. An autosave already on its
// way when that happens would otherwise land afterwards and bring the draft
// back, so every save checks this before and after it talks to the server.
const discardedRef = useRef(false);
const editorRef = useRef<HTMLDivElement>(null);
// The body, mirrored into React state.
//
// It used to live only inside the contenteditable, read through the ref at
// save time. Nothing observed it, so the autosave effect - which depends on
// what it reads - never re-ran when the message itself was typed. The draft
// was written once, moments after the address was entered, and never again:
// that is why a draft left behind by a sent message was empty.
const [body, setBody] = useState("");
useEffect(() => {
const savedSig = localStorage.getItem("lm_user_signature");
// signatureToHtml, not the raw value: the signature may be the plain text
// of the old field, whose newlines are whitespace once assigned as HTML -
// which is why every line break in a signature used to vanish here.
const signatureHtml = signatureToHtml(savedSig ?? "");
if (editorRef.current && signatureHtml) {
const initial = `<br/><br/>--<br/>${signatureHtml}`;
editorRef.current.innerHTML = initial;
setBody(initial);
}
}, []);
// Reopening a draft loads what was written into the composer, and adopts its
// UID so carrying on writing replaces that draft instead of leaving the old
// copy behind and starting a second one.
//
// Opening a draft used to land in the reader, which offered to reply to the
// half-written message and gave no way to finish it.
const draftParam = params?.draft;
useEffect(() => {
const uid = Number(draftParam);
if (!Number.isFinite(uid) || uid <= 0) return;
let cancelled = false;
void (async () => {
try {
const res = await fetch(`/api/v1/mail/message/${uid}?folder=drafts`, { cache: "no-store" });
if (!res.ok || cancelled) return;
const message = await res.json();
if (cancelled) return;
draftUidRef.current = uid;
setTo(message.to ?? []);
setCc(message.cc ?? []);
setImportant(Boolean(message.important));
setInReplyTo(message.in_reply_to ?? "");
if ((message.cc ?? []).length > 0) setShowCcBcc(true);
setSubject(message.subject ?? "");
const html = message.html || message.text || "";
if (editorRef.current) editorRef.current.innerHTML = html;
setBody(html);
} catch {
// A draft that cannot be loaded leaves an empty composer rather than
// a broken screen; the draft itself is untouched on the server.
}
})();
return () => {
cancelled = true;
};
}, [draftParam]);
// Removes a stored draft for good. discard_draft is what keeps it out of
// Trash: an ordinary delete moves it there, and a draft sitting in Trash is
// not discarded.
const removeDraft = async (uid: number): Promise<boolean> => {
try {
const res = await fetch("/api/v1/mail/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ folder: "drafts", uid, discard_draft: true }),
});
return res.ok;
} catch {
return false;
}
};
// Saves the draft for real. This used to be a setTimeout that set the words
// "draft saved automatically" and nothing else - no request, no storage - so
// closing the tab lost the message while the screen had just said otherwise.
const saveDraft = useCallback(async () => {
if (discardedRef.current) return;
const html = editorRef.current?.innerHTML ?? body;
if (to.length === 0 && !subject && !body.trim() && !html.trim()) return;
setDraftStatus(t("mail.draftSaving"));
try {
const res = await fetch("/api/v1/mail/draft", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
to,
cc,
bcc,
subject,
html,
important,
in_reply_to: inReplyTo,
replace_uid: draftUidRef.current,
}),
});
if (res.ok) {
const data = await res.json().catch(() => ({}));
if (typeof data.uid === "number") draftUidRef.current = data.uid;
// Discarded while this save was in flight: what it just stored is a
// draft nobody wants, so it goes too.
if (discardedRef.current && draftUidRef.current > 0) {
void removeDraft(draftUidRef.current);
return;
}
}
setDraftStatus(res.ok ? t("mail.draftSaved") : t("mail.draftSaveFailed"));
} catch {
setDraftStatus(t("mail.draftSaveFailed"));
}
}, [to, cc, bcc, subject, body, important, inReplyTo, t]);
// Debounced so a draft is written after a pause in typing rather than on
// every keystroke. Skipped once the message is on its way out.
useEffect(() => {
if (sending) return;
// The body alone is enough: a message typed before its recipient is still
// worth keeping, and requiring an address here is half of why drafts were
// being stored with nothing in them.
if (to.length === 0 && !subject && !body.trim()) return;
const timer = setTimeout(() => void saveDraft(), 2500);
return () => clearTimeout(timer);
}, [to, subject, cc, bcc, body, important, sending, saveDraft]);
// Throws the message away: stops the autosave, removes the draft already
// stored for it, and only then leaves. Leaving first was the old behaviour -
// a link to the inbox - and left the draft in Drafts, as if nothing had
// been discarded.
const discardMessage = async () => {
discardedRef.current = true;
setDiscarding(true);
setSendError(null);
const uid = draftUidRef.current;
if (uid > 0 && !(await removeDraft(uid))) {
// Still there, so say so and let the autosave carry on rather than
// leaving the page with a draft the writer thinks is gone.
discardedRef.current = false;
setDiscarding(false);
setSendError(t("mail.discardFailed"));
return;
}
draftUidRef.current = 0;
notifyMailStateChanged();
window.location.href = "/user/mail/inbox";
};
const executeFormat = (command: string, value: string | undefined = undefined) => {
document.execCommand(command, false, value);
editorRef.current?.focus();
// execCommand edits the DOM directly, so the mirrored body is re-read
// here rather than relying on an input event that may not be dispatched.
setBody(editorRef.current?.innerHTML ?? "");
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files) return;
const newFiles: AttachedFile[] = Array.from(e.target.files).map((f) => ({
id: Math.random().toString(36).substring(2, 9),
name: f.name,
size: f.size,
type: f.type || "application/octet-stream",
}));
setAttachments((prev) => [...prev, ...newFiles]);
};
const removeAttachment = (id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id));
};
// Anything in a recipient field that is not an address. These stay on screen
// as red chips; sending with one would queue mail to a destination that
// cannot exist and report success.
const invalidRecipients = [...to, ...cc, ...bcc].filter((entry) => !parseAddress(entry).valid);
const hasRecipient = to.length + cc.length + bcc.length > 0;
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!hasRecipient) {
setSendError(t("mail.noRecipients"));
return;
}
if (invalidRecipients.length > 0) {
setSendError(t("mail.fixRecipients"));
setShowCcBcc(true);
return;
}
setSendError(null);
setSending(true);
setUndoSeconds(10);
const bodyHtml = editorRef.current?.innerHTML || body;
const interval = setInterval(() => {
setUndoSeconds((prev) => {
if (prev === null || prev <= 1) {
clearInterval(interval);
void sendActualMessage(bodyHtml);
return null;
}
return prev - 1;
});
}, 1000);
};
const sendActualMessage = async (htmlBody: string) => {
try {
const res = await fetch("/api/v1/mail/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
to,
cc,
bcc,
subject,
html: htmlBody,
important,
in_reply_to: inReplyTo,
// The draft this message grew out of, so the server can discard it.
// Without this the draft outlived the message it became and sat in
// Drafts as a duplicate of mail already sent.
draft_uid: draftUidRef.current,
}),
});
if (!res.ok) {
// Reporting success on a refused send is how a message silently
// disappears: the composer used to navigate away regardless.
const data = await res.json().catch(() => ({}));
setSendError(data.message ?? t("errors.serverError"));
setSending(false);
return;
}
window.location.href = "/user/mail/sent";
} catch {
setSendError(t("errors.serverError"));
setSending(false);
}
};
const handleUndo = () => {
setUndoSeconds(null);
setSending(false);
};
/** One row of the compose sheet: a fixed-width label beside the control. */
const fieldRow = "flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2.5 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.07)]";
const fieldLabel = "w-14 flex-none text-[12.5px] leading-snug text-slate-400";
const bareInput =
"min-h-[30px] min-w-[160px] flex-1 border-0 bg-transparent text-sm text-slate-100 placeholder-slate-500 focus:outline-none";
const toolButton =
"flex h-8 w-8 items-center justify-center rounded-lg text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-slate-100";
return (
<div className="flex-1 overflow-y-auto bg-dark-bg px-5 pb-11 pt-7 sm:px-8">
<form onSubmit={handleSend} className="mx-auto flex w-full max-w-[860px] flex-col gap-[18px]">
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="min-w-0">
<h1 className="text-[25px] font-medium leading-tight text-slate-100">{t("mail.compose")}</h1>
{draftStatus && (
<div className="mt-1 flex items-center gap-1.5 text-[12.5px] text-slate-400">
<CheckCircle2 className="h-3.5 w-3.5 flex-none text-indigo-500" />
{draftStatus}
</div>
)}
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setImportant((v) => !v)}
aria-pressed={important}
disabled={sending}
title={t("mail.highImportanceHint")}
className={`inline-flex h-10 items-center gap-2 rounded-xl px-3.5 text-sm shadow-edge transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 ${
important ? "bg-rose-900/70 text-rose-100" : "bg-dark-card text-slate-200 hover:bg-white/[0.07]"
}`}
>
<Flag className="h-4 w-4" fill={important ? "currentColor" : "none"} />
<span>{t("mail.highImportance")}</span>
</button>
<Button
type="button"
variant="secondary"
size="md"
disabled={sending || discarding}
onClick={() => void discardMessage()}
>
{t("mail.discard")}
</Button>
<Button type="submit" variant="primary" size="md" disabled={sending}>
<Send className="h-4 w-4" />
<span>{sending ? t("mail.sending") : t("mail.sendMessage")}</span>
</Button>
</div>
</div>
{sendError && (
<div className="rounded-xl bg-rose-900/60 px-4 py-3 text-xs leading-relaxed text-rose-200 shadow-edge">
{sendError}
</div>
)}
{undoSeconds !== null && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl bg-dark-card px-4 py-3 text-xs text-slate-200 shadow-edge">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 flex-none animate-spin text-indigo-500" />
{/* The countdown is a placeholder inside the message, not text
bolted on after it. It used to be both: the bundle's own
"{seconds}" was never substituted and a second, hardcoded
count was appended, so this read "Undo Send ({seconds}s
remaining) (9s remaining)". */}
<span>{t("mail.undoSend", { seconds: undoSeconds })}</span>
</div>
<Button variant="primary" size="sm" onClick={handleUndo}>
<Undo2 className="h-3.5 w-3.5" />
{t("mail.undoSendAction")}
</Button>
</div>
)}
{/* One sheet, the fields stacked as rows inside it, rather than a stack
of separately outlined inputs: the message is one object. */}
<div className="overflow-hidden rounded-2xl bg-dark-panel shadow-edge">
<div className={fieldRow}>
<label htmlFor="compose-to" className={fieldLabel}>
{t("ui.toRecipients")}
</label>
<RecipientInput
id="compose-to"
value={to}
onChange={setTo}
placeholder="recipient@example.com"
disabled={sending}
invalid={to.some((e) => !parseAddress(e).valid)}
/>
<button
type="button"
onClick={() => setShowCcBcc(!showCcBcc)}
className="flex-none rounded-lg px-2 py-1 text-[12.5px] text-indigo-500 transition-colors hover:bg-indigo-500/10"
>
{showCcBcc ? t("mail.hideCcBcc") : t("mail.showCcBcc")}
</button>
</div>
{showCcBcc && (
<>
<div className={fieldRow}>
<label htmlFor="compose-cc" className={fieldLabel}>
{t("mail.ccLabel")}
</label>
<RecipientInput
id="compose-cc"
value={cc}
onChange={setCc}
placeholder="cc@domain.com"
disabled={sending}
invalid={cc.some((e) => !parseAddress(e).valid)}
/>
</div>
<div className={fieldRow}>
<label htmlFor="compose-bcc" className={fieldLabel}>
{t("mail.bccLabel")}
</label>
<RecipientInput
id="compose-bcc"
value={bcc}
onChange={setBcc}
placeholder="bcc@domain.com"
disabled={sending}
invalid={bcc.some((e) => !parseAddress(e).valid)}
/>
</div>
</>
)}
<div className={fieldRow}>
<label htmlFor="compose-subject" className={fieldLabel}>
{t("ui.subject")}
</label>
<input
id="compose-subject"
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder={t("ui.subject")}
required
disabled={sending}
className={bareInput}
/>
</div>
{/* Formatting Toolbar */}
<div className="flex flex-wrap items-center gap-0.5 px-3 py-2 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.07)]">
<button type="button" onClick={() => executeFormat("bold")} className={toolButton} title={t("mail.bold")}>
<Bold className="h-4 w-4" />
</button>
<button type="button" onClick={() => executeFormat("italic")} className={toolButton} title={t("mail.italic")}>
<Italic className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => executeFormat("underline")}
className={toolButton}
title={t("mail.underline")}
>
<Underline className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => executeFormat("strikeThrough")}
className={toolButton}
title={t("mail.strikethrough")}
>
<Strikethrough className="h-4 w-4" />
</button>
<span className="mx-1.5 h-[18px] w-px bg-white/[0.14]" />
<button
type="button"
onClick={() => executeFormat("insertUnorderedList")}
className={toolButton}
title={t("mail.bulletList")}
>
<List className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => executeFormat("insertOrderedList")}
className={toolButton}
title={t("mail.numberedList")}
>
<ListOrdered className="h-4 w-4" />
</button>
<span className="mx-1.5 h-[18px] w-px bg-white/[0.14]" />
<button
type="button"
onClick={() => {
const url = prompt(t("mail.insertUrl"));
if (url) executeFormat("createLink", url);
<Composer
initial={params}
variant="page"
onSent={() => {
window.location.href = "/user/mail/sent";
}}
onDiscarded={() => {
window.location.href = "/user/mail/inbox";
}}
className={`${toolButton} text-indigo-500`}
title={t("mail.insertLink")}
>
<LinkIcon className="h-4 w-4" />
</button>
</div>
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
onInput={(e) => setBody((e.target as HTMLDivElement).innerHTML)}
onBlur={(e) => setBody((e.target as HTMLDivElement).innerHTML)}
aria-label={t("ui.messageBody")}
role="textbox"
aria-multiline="true"
className="min-h-[280px] px-[22px] py-5 font-sans text-[14.5px] leading-relaxed text-slate-200 focus:outline-none"
/>
{/* Attachment Selector & Display */}
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3.5 shadow-[inset_0_1px_0_0_rgba(233,233,237,0.07)]">
<label className="flex flex-none cursor-pointer items-center gap-2 rounded-[10px] border border-white/[0.14] px-3 py-2 text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07]">
<Paperclip className="h-[15px] w-[15px]" />
<span>{t("mail.addFiles")}</span>
<input type="file" multiple onChange={handleFileUpload} className="hidden" />
</label>
{attachments.map((file) => (
<div
key={file.id}
className="flex items-center gap-2 rounded-[10px] bg-dark-card px-3 py-2 text-[12.5px] text-slate-200 shadow-edge"
>
<Paperclip className="h-[15px] w-[15px] flex-none text-indigo-400" />
<span className="max-w-[160px] truncate">{file.name}</span>
<span className="text-[11px] text-slate-500">{(file.size / 1024).toFixed(1)} KB</span>
<button
type="button"
onClick={() => removeAttachment(file.id)}
className="text-slate-400 transition-colors hover:text-rose-400"
aria-label={t("common.delete")}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
{/* Says so plainly rather than letting someone attach a contract
and watch it silently not arrive: /mail/send is handed the file
names only, because there is no upload endpoint behind this
picker yet. */}
{attachments.length > 0 && (
<span className="ml-auto text-[11.5px] leading-relaxed text-slate-400">
{t("mail.attachmentsUnsupported")}
</span>
)}
</div>
</div>
</form>
</div>
);
}
@@ -0,0 +1,603 @@
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Send,
Paperclip,
Bold,
Italic,
Underline,
Strikethrough,
List,
ListOrdered,
Link as LinkIcon,
Undo2,
X,
Sparkles,
CheckCircle2,
Flag,
} from "lucide-react";
import { useTranslations } from "../i18n/provider";
import { Button } from "../components/ui/Button";
import { notifyMailStateChanged } from "../lib/useFolders";
import { signatureToHtml } from "../lib/signature";
import { RecipientInput } from "../components/RecipientInput";
import { parseAddress, parseAddressList, prefixSubject } from "../lib/recipients";
/** Entries from a query-string list, which may be several addresses. */
function entriesFrom(value: string | undefined): string[] {
return value ? parseAddressList(value).map((p) => p.entry) : [];
}
interface AttachedFile {
id: string;
name: string;
size: number;
type: string;
}
export interface ComposerInitial {
replyTo?: string;
cc?: string;
subject?: string;
inReplyTo?: string;
mode?: string;
draft?: string;
}
interface ComposerProps {
initial?: ComposerInitial;
/**
* "page" is the full compose screen. "inline" is the reply written under the
* conversation it answers: no title, no subject row (a reply keeps its
* subject) and a shorter editor, so the messages stay in view above it.
*/
variant?: "page" | "inline";
onSent: () => void;
onDiscarded: () => void;
}
export function Composer({ initial, variant = "page", onSent, onDiscarded }: ComposerProps) {
const t = useTranslations();
const inline = variant === "inline";
const params = initial ?? {};
// Recipients are lists of entries, not one string to be split later: a
// reply-all arrives with several people already in To and Cc.
const [to, setTo] = useState<string[]>(() => entriesFrom(params?.replyTo));
const [cc, setCc] = useState<string[]>(() => entriesFrom(params?.cc));
const [bcc, setBcc] = useState<string[]>([]);
const [showCcBcc, setShowCcBcc] = useState(() => entriesFrom(params?.cc).length > 0);
const [subject, setSubject] = useState(
params?.subject ? prefixSubject(params.subject, params.mode === "forward" ? "forward" : "reply") : "",
);
// The Message-ID being answered, so the reply threads under it.
const [inReplyTo, setInReplyTo] = useState(params?.mode === "forward" ? "" : params?.inReplyTo || "");
const [important, setImportant] = useState(false);
const [attachments, setAttachments] = useState<AttachedFile[]>([]);
const [sending, setSending] = useState(false);
const [undoSeconds, setUndoSeconds] = useState<number | null>(null);
const [draftStatus, setDraftStatus] = useState<string>("");
const [discarding, setDiscarding] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
// The UID of the draft already stored for this message. Sent back on each
// autosave so the server replaces it instead of leaving another copy.
const draftUidRef = useRef<number>(0);
// Set once the writer has discarded the message. An autosave already on its
// way when that happens would otherwise land afterwards and bring the draft
// back, so every save checks this before and after it talks to the server.
const discardedRef = useRef(false);
const editorRef = useRef<HTMLDivElement>(null);
// The body, mirrored into React state.
//
// It used to live only inside the contenteditable, read through the ref at
// save time. Nothing observed it, so the autosave effect - which depends on
// what it reads - never re-ran when the message itself was typed. The draft
// was written once, moments after the address was entered, and never again:
// that is why a draft left behind by a sent message was empty.
const [body, setBody] = useState("");
// An inline reply is opened to be written in, so the cursor starts in it.
useEffect(() => {
if (inline) editorRef.current?.focus();
}, [inline]);
useEffect(() => {
const savedSig = localStorage.getItem("lm_user_signature");
// signatureToHtml, not the raw value: the signature may be the plain text
// of the old field, whose newlines are whitespace once assigned as HTML -
// which is why every line break in a signature used to vanish here.
const signatureHtml = signatureToHtml(savedSig ?? "");
if (editorRef.current && signatureHtml) {
const initial = `<br/><br/>--<br/>${signatureHtml}`;
editorRef.current.innerHTML = initial;
setBody(initial);
}
}, []);
// Reopening a draft loads what was written into the composer, and adopts its
// UID so carrying on writing replaces that draft instead of leaving the old
// copy behind and starting a second one.
//
// Opening a draft used to land in the reader, which offered to reply to the
// half-written message and gave no way to finish it.
const draftParam = params?.draft;
useEffect(() => {
const uid = Number(draftParam);
if (!Number.isFinite(uid) || uid <= 0) return;
let cancelled = false;
void (async () => {
try {
const res = await fetch(`/api/v1/mail/message/${uid}?folder=drafts`, { cache: "no-store" });
if (!res.ok || cancelled) return;
const message = await res.json();
if (cancelled) return;
draftUidRef.current = uid;
setTo(message.to ?? []);
setCc(message.cc ?? []);
setImportant(Boolean(message.important));
setInReplyTo(message.in_reply_to ?? "");
if ((message.cc ?? []).length > 0) setShowCcBcc(true);
setSubject(message.subject ?? "");
const html = message.html || message.text || "";
if (editorRef.current) editorRef.current.innerHTML = html;
setBody(html);
} catch {
// A draft that cannot be loaded leaves an empty composer rather than
// a broken screen; the draft itself is untouched on the server.
}
})();
return () => {
cancelled = true;
};
}, [draftParam]);
// Removes a stored draft for good. discard_draft is what keeps it out of
// Trash: an ordinary delete moves it there, and a draft sitting in Trash is
// not discarded.
const removeDraft = async (uid: number): Promise<boolean> => {
try {
const res = await fetch("/api/v1/mail/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ folder: "drafts", uid, discard_draft: true }),
});
return res.ok;
} catch {
return false;
}
};
// Saves the draft for real. This used to be a setTimeout that set the words
// "draft saved automatically" and nothing else - no request, no storage - so
// closing the tab lost the message while the screen had just said otherwise.
const saveDraft = useCallback(async () => {
if (discardedRef.current) return;
const html = editorRef.current?.innerHTML ?? body;
if (to.length === 0 && !subject && !body.trim() && !html.trim()) return;
setDraftStatus(t("mail.draftSaving"));
try {
const res = await fetch("/api/v1/mail/draft", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
to,
cc,
bcc,
subject,
html,
important,
in_reply_to: inReplyTo,
replace_uid: draftUidRef.current,
}),
});
if (res.ok) {
const data = await res.json().catch(() => ({}));
if (typeof data.uid === "number") draftUidRef.current = data.uid;
// Discarded while this save was in flight: what it just stored is a
// draft nobody wants, so it goes too.
if (discardedRef.current && draftUidRef.current > 0) {
void removeDraft(draftUidRef.current);
return;
}
}
setDraftStatus(res.ok ? t("mail.draftSaved") : t("mail.draftSaveFailed"));
} catch {
setDraftStatus(t("mail.draftSaveFailed"));
}
}, [to, cc, bcc, subject, body, important, inReplyTo, t]);
// Debounced so a draft is written after a pause in typing rather than on
// every keystroke. Skipped once the message is on its way out.
useEffect(() => {
if (sending) return;
// The body alone is enough: a message typed before its recipient is still
// worth keeping, and requiring an address here is half of why drafts were
// being stored with nothing in them.
if (to.length === 0 && !subject && !body.trim()) return;
const timer = setTimeout(() => void saveDraft(), 2500);
return () => clearTimeout(timer);
}, [to, subject, cc, bcc, body, important, sending, saveDraft]);
// Throws the message away: stops the autosave, removes the draft already
// stored for it, and only then leaves. Leaving first was the old behaviour -
// a link to the inbox - and left the draft in Drafts, as if nothing had
// been discarded.
const discardMessage = async () => {
discardedRef.current = true;
setDiscarding(true);
setSendError(null);
const uid = draftUidRef.current;
if (uid > 0 && !(await removeDraft(uid))) {
// Still there, so say so and let the autosave carry on rather than
// leaving the page with a draft the writer thinks is gone.
discardedRef.current = false;
setDiscarding(false);
setSendError(t("mail.discardFailed"));
return;
}
draftUidRef.current = 0;
notifyMailStateChanged();
onDiscarded();
};
const executeFormat = (command: string, value: string | undefined = undefined) => {
document.execCommand(command, false, value);
editorRef.current?.focus();
// execCommand edits the DOM directly, so the mirrored body is re-read
// here rather than relying on an input event that may not be dispatched.
setBody(editorRef.current?.innerHTML ?? "");
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files) return;
const newFiles: AttachedFile[] = Array.from(e.target.files).map((f) => ({
id: Math.random().toString(36).substring(2, 9),
name: f.name,
size: f.size,
type: f.type || "application/octet-stream",
}));
setAttachments((prev) => [...prev, ...newFiles]);
};
const removeAttachment = (id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id));
};
// Anything in a recipient field that is not an address. These stay on screen
// as red chips; sending with one would queue mail to a destination that
// cannot exist and report success.
const invalidRecipients = [...to, ...cc, ...bcc].filter((entry) => !parseAddress(entry).valid);
const hasRecipient = to.length + cc.length + bcc.length > 0;
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!hasRecipient) {
setSendError(t("mail.noRecipients"));
return;
}
if (invalidRecipients.length > 0) {
setSendError(t("mail.fixRecipients"));
setShowCcBcc(true);
return;
}
setSendError(null);
setSending(true);
setUndoSeconds(10);
const bodyHtml = editorRef.current?.innerHTML || body;
const interval = setInterval(() => {
setUndoSeconds((prev) => {
if (prev === null || prev <= 1) {
clearInterval(interval);
void sendActualMessage(bodyHtml);
return null;
}
return prev - 1;
});
}, 1000);
};
const sendActualMessage = async (htmlBody: string) => {
try {
const res = await fetch("/api/v1/mail/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
to,
cc,
bcc,
subject,
html: htmlBody,
important,
in_reply_to: inReplyTo,
// The draft this message grew out of, so the server can discard it.
// Without this the draft outlived the message it became and sat in
// Drafts as a duplicate of mail already sent.
draft_uid: draftUidRef.current,
}),
});
if (!res.ok) {
// Reporting success on a refused send is how a message silently
// disappears: the composer used to navigate away regardless.
const data = await res.json().catch(() => ({}));
setSendError(data.message ?? t("errors.serverError"));
setSending(false);
return;
}
onSent();
} catch {
setSendError(t("errors.serverError"));
setSending(false);
}
};
const handleUndo = () => {
setUndoSeconds(null);
setSending(false);
};
/** One row of the compose sheet: a fixed-width label beside the control. */
const fieldRow = "flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2.5 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.07)]";
const fieldLabel = "w-14 flex-none text-[12.5px] leading-snug text-slate-400";
const bareInput =
"min-h-[30px] min-w-[160px] flex-1 border-0 bg-transparent text-sm text-slate-100 placeholder-slate-500 focus:outline-none";
const toolButton =
"flex h-8 w-8 items-center justify-center rounded-lg text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-slate-100";
return (
<div className={inline ? "" : "flex-1 overflow-y-auto bg-dark-bg px-5 pb-11 pt-7 sm:px-8"}>
<form
onSubmit={handleSend}
className={inline ? "flex w-full flex-col gap-3" : "mx-auto flex w-full max-w-[860px] flex-col gap-[18px]"}
>
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="min-w-0">
{!inline && <h1 className="text-[25px] font-medium leading-tight text-slate-100">{t("mail.compose")}</h1>}
{draftStatus && (
<div className="mt-1 flex items-center gap-1.5 text-[12.5px] text-slate-400">
<CheckCircle2 className="h-3.5 w-3.5 flex-none text-indigo-500" />
{draftStatus}
</div>
)}
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setImportant((v) => !v)}
aria-pressed={important}
disabled={sending}
title={t("mail.highImportanceHint")}
className={`inline-flex h-10 items-center gap-2 rounded-xl px-3.5 text-sm shadow-edge transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 ${
important ? "bg-rose-900/70 text-rose-100" : "bg-dark-card text-slate-200 hover:bg-white/[0.07]"
}`}
>
<Flag className="h-4 w-4" fill={important ? "currentColor" : "none"} />
<span>{t("mail.highImportance")}</span>
</button>
<Button
type="button"
variant="secondary"
size="md"
disabled={sending || discarding}
onClick={() => void discardMessage()}
>
{t("mail.discard")}
</Button>
<Button type="submit" variant="primary" size="md" disabled={sending}>
<Send className="h-4 w-4" />
<span>{sending ? t("mail.sending") : t("mail.sendMessage")}</span>
</Button>
</div>
</div>
{sendError && (
<div className="rounded-xl bg-rose-900/60 px-4 py-3 text-xs leading-relaxed text-rose-200 shadow-edge">
{sendError}
</div>
)}
{undoSeconds !== null && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl bg-dark-card px-4 py-3 text-xs text-slate-200 shadow-edge">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 flex-none animate-spin text-indigo-500" />
{/* The countdown is a placeholder inside the message, not text
bolted on after it. It used to be both: the bundle's own
"{seconds}" was never substituted and a second, hardcoded
count was appended, so this read "Undo Send ({seconds}s
remaining) (9s remaining)". */}
<span>{t("mail.undoSend", { seconds: undoSeconds })}</span>
</div>
<Button variant="primary" size="sm" onClick={handleUndo}>
<Undo2 className="h-3.5 w-3.5" />
{t("mail.undoSendAction")}
</Button>
</div>
)}
{/* One sheet, the fields stacked as rows inside it, rather than a stack
of separately outlined inputs: the message is one object. */}
<div className="overflow-hidden rounded-2xl bg-dark-panel shadow-edge">
<div className={fieldRow}>
<label htmlFor="compose-to" className={fieldLabel}>
{t("ui.toRecipients")}
</label>
<RecipientInput
id="compose-to"
value={to}
onChange={setTo}
placeholder="recipient@example.com"
disabled={sending}
invalid={to.some((e) => !parseAddress(e).valid)}
/>
<button
type="button"
onClick={() => setShowCcBcc(!showCcBcc)}
className="flex-none rounded-lg px-2 py-1 text-[12.5px] text-indigo-500 transition-colors hover:bg-indigo-500/10"
>
{showCcBcc ? t("mail.hideCcBcc") : t("mail.showCcBcc")}
</button>
</div>
{showCcBcc && (
<>
<div className={fieldRow}>
<label htmlFor="compose-cc" className={fieldLabel}>
{t("mail.ccLabel")}
</label>
<RecipientInput
id="compose-cc"
value={cc}
onChange={setCc}
placeholder="cc@domain.com"
disabled={sending}
invalid={cc.some((e) => !parseAddress(e).valid)}
/>
</div>
<div className={fieldRow}>
<label htmlFor="compose-bcc" className={fieldLabel}>
{t("mail.bccLabel")}
</label>
<RecipientInput
id="compose-bcc"
value={bcc}
onChange={setBcc}
placeholder="bcc@domain.com"
disabled={sending}
invalid={bcc.some((e) => !parseAddress(e).valid)}
/>
</div>
</>
)}
{!inline && (
<div className={fieldRow}>
<label htmlFor="compose-subject" className={fieldLabel}>
{t("ui.subject")}
</label>
<input
id="compose-subject"
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder={t("ui.subject")}
required
disabled={sending}
className={bareInput}
/>
</div>
)}
{/* Formatting Toolbar */}
<div className="flex flex-wrap items-center gap-0.5 px-3 py-2 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.07)]">
<button type="button" onClick={() => executeFormat("bold")} className={toolButton} title={t("mail.bold")}>
<Bold className="h-4 w-4" />
</button>
<button type="button" onClick={() => executeFormat("italic")} className={toolButton} title={t("mail.italic")}>
<Italic className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => executeFormat("underline")}
className={toolButton}
title={t("mail.underline")}
>
<Underline className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => executeFormat("strikeThrough")}
className={toolButton}
title={t("mail.strikethrough")}
>
<Strikethrough className="h-4 w-4" />
</button>
<span className="mx-1.5 h-[18px] w-px bg-white/[0.14]" />
<button
type="button"
onClick={() => executeFormat("insertUnorderedList")}
className={toolButton}
title={t("mail.bulletList")}
>
<List className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => executeFormat("insertOrderedList")}
className={toolButton}
title={t("mail.numberedList")}
>
<ListOrdered className="h-4 w-4" />
</button>
<span className="mx-1.5 h-[18px] w-px bg-white/[0.14]" />
<button
type="button"
onClick={() => {
const url = prompt(t("mail.insertUrl"));
if (url) executeFormat("createLink", url);
}}
className={`${toolButton} text-indigo-500`}
title={t("mail.insertLink")}
>
<LinkIcon className="h-4 w-4" />
</button>
</div>
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
onInput={(e) => setBody((e.target as HTMLDivElement).innerHTML)}
onBlur={(e) => setBody((e.target as HTMLDivElement).innerHTML)}
aria-label={t("ui.messageBody")}
role="textbox"
aria-multiline="true"
className={`${inline ? "min-h-[150px]" : "min-h-[280px]"} px-[22px] py-5 font-sans text-[14.5px] leading-relaxed text-slate-200 focus:outline-none`}
/>
{/* Attachment Selector & Display */}
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3.5 shadow-[inset_0_1px_0_0_rgba(233,233,237,0.07)]">
<label className="flex flex-none cursor-pointer items-center gap-2 rounded-[10px] border border-white/[0.14] px-3 py-2 text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07]">
<Paperclip className="h-[15px] w-[15px]" />
<span>{t("mail.addFiles")}</span>
<input type="file" multiple onChange={handleFileUpload} className="hidden" />
</label>
{attachments.map((file) => (
<div
key={file.id}
className="flex items-center gap-2 rounded-[10px] bg-dark-card px-3 py-2 text-[12.5px] text-slate-200 shadow-edge"
>
<Paperclip className="h-[15px] w-[15px] flex-none text-indigo-400" />
<span className="max-w-[160px] truncate">{file.name}</span>
<span className="text-[11px] text-slate-500">{(file.size / 1024).toFixed(1)} KB</span>
<button
type="button"
onClick={() => removeAttachment(file.id)}
className="text-slate-400 transition-colors hover:text-rose-400"
aria-label={t("common.delete")}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
{/* Says so plainly rather than letting someone attach a contract
and watch it silently not arrive: /mail/send is handed the file
names only, because there is no upload endpoint behind this
picker yet. */}
{attachments.length > 0 && (
<span className="ml-auto text-[11.5px] leading-relaxed text-slate-400">
{t("mail.attachmentsUnsupported")}
</span>
)}
</div>
</div>
</form>
</div>
);
}
@@ -18,7 +18,7 @@ const code = (path: string) =>
.replace(/\{\/\*[\s\S]*?\*\/\}/g, "")
.replace(/^\s*\/\/.*$/gm, "");
const COMPOSE = "src/app/(user)/user/compose/page.tsx";
const COMPOSE = "src/components/Composer.tsx";
const MAIL_PAGE = "src/app/(user)/user/mail/[folder]/page.tsx";
describe("the draft a sent message leaves behind", () => {