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";
|
||||
|
||||
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);
|
||||
}}
|
||||
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>
|
||||
<Composer
|
||||
initial={params}
|
||||
variant="page"
|
||||
onSent={() => {
|
||||
window.location.href = "/user/mail/sent";
|
||||
}}
|
||||
onDiscarded={() => {
|
||||
window.location.href = "/user/mail/inbox";
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user