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:
1 parent
997dd8848f
commit
86a69d1a96
3 files changed
+621
-577
No files matched your search
@@ -1,583 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useRef, useState, use } from "react";
|
import { use } from "react";
|
||||||
import Link from "next/link";
|
import { Composer, type ComposerInitial } from "../../../../components/Composer";
|
||||||
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[] {
|
* The full compose screen. The composer itself lives in components/Composer so
|
||||||
return value ? parseAddressList(value).map((p) => p.entry) : [];
|
* the reply written under a conversation is the same code, not a copy of it.
|
||||||
}
|
*/
|
||||||
|
export default function ComposePage({ searchParams }: { searchParams?: Promise<ComposerInitial> }) {
|
||||||
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();
|
|
||||||
const params = searchParams ? use(searchParams) : {};
|
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 (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto bg-dark-bg px-5 pb-11 pt-7 sm:px-8">
|
<Composer
|
||||||
<form onSubmit={handleSend} className="mx-auto flex w-full max-w-[860px] flex-col gap-[18px]">
|
initial={params}
|
||||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
variant="page"
|
||||||
<div className="min-w-0">
|
onSent={() => {
|
||||||
<h1 className="text-[25px] font-medium leading-tight text-slate-100">{t("mail.compose")}</h1>
|
window.location.href = "/user/mail/sent";
|
||||||
{draftStatus && (
|
}}
|
||||||
<div className="mt-1 flex items-center gap-1.5 text-[12.5px] text-slate-400">
|
onDiscarded={() => {
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 flex-none text-indigo-500" />
|
window.location.href = "/user/mail/inbox";
|
||||||
{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);
|
|
||||||
}}
|
|
||||||
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\S]*?\*\/\}/g, "")
|
||||||
.replace(/^\s*\/\/.*$/gm, "");
|
.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";
|
const MAIL_PAGE = "src/app/(user)/user/mail/[folder]/page.tsx";
|
||||||
|
|
||||||
describe("the draft a sent message leaves behind", () => {
|
describe("the draft a sent message leaves behind", () => {
|
||||||
|
|||||||
Reference in new issue
Block a user