A mark (a lambda on an ultramarine tile with the flap of an envelope), the favicon, home-screen icons and an install manifest. Every colour is a variable defined once per theme, and the Tailwind scales the screens already use read them, so dark (the default) and light are a change of values and not of classes. Geist is served by the app itself. Ctrl+K opens a command menu for folders, actions, accounts and appearance, and typing something that matches no command searches the mail; c writes a message and / goes to the search field. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
642 lines
25 KiB
TypeScript
642 lines
25 KiB
TypeScript
"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);
|
|
// The send waiting out its ten seconds, so Undo can stop it and leaving the
|
|
// screen cannot lose it.
|
|
const pendingSend = useRef<{ timer: ReturnType<typeof setInterval>; html: string } | null>(null);
|
|
const sendRef = useRef<(html: string) => Promise<void>>(async () => undefined);
|
|
const unmounted = 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("");
|
|
|
|
// Leaving with a send still counting down sends it now. Dropping it would lose
|
|
// a message the writer was told was on its way; Undo is the way to cancel.
|
|
useEffect(() => {
|
|
unmounted.current = false;
|
|
return () => {
|
|
unmounted.current = true;
|
|
const pending = pendingSend.current;
|
|
if (pending) {
|
|
clearInterval(pending.timer);
|
|
pendingSend.current = null;
|
|
void sendRef.current(pending.html);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// 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;
|
|
|
|
// The countdown is counted here and not inside a state updater. The send
|
|
// used to be a side effect of that updater, which React may run twice (the
|
|
// message went out twice) and which Undo could not stop: Undo only cleared
|
|
// the number, and the next tick - finding it empty - sent the message
|
|
// anyway.
|
|
let remaining = 10;
|
|
const timer = setInterval(() => {
|
|
remaining -= 1;
|
|
if (remaining > 0) {
|
|
setUndoSeconds(remaining);
|
|
return;
|
|
}
|
|
clearInterval(timer);
|
|
pendingSend.current = null;
|
|
setUndoSeconds(null);
|
|
void sendRef.current(bodyHtml);
|
|
}, 1000);
|
|
pendingSend.current = { timer, html: bodyHtml };
|
|
};
|
|
|
|
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;
|
|
}
|
|
// A flush after leaving must not drag the writer back to Sent.
|
|
if (!unmounted.current) onSent();
|
|
} catch {
|
|
setSendError(t("errors.serverError"));
|
|
setSending(false);
|
|
}
|
|
};
|
|
|
|
sendRef.current = sendActualMessage;
|
|
|
|
const handleUndo = () => {
|
|
// Stops the timer itself: clearing the number on screen is not enough.
|
|
if (pendingSend.current) {
|
|
clearInterval(pendingSend.current.timer);
|
|
pendingSend.current = null;
|
|
}
|
|
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_rgb(var(--ink)/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-ink/[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-ink/[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>
|
|
{/* type="button": inside this form a button submits it, so Undo used to
|
|
stop the countdown and then send the message again. */}
|
|
<Button type="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_rgb(var(--ink)/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-ink/[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-ink/[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_rgb(var(--ink)/0.07)]">
|
|
<label className="flex flex-none cursor-pointer items-center gap-2 rounded-[10px] border border-ink/[0.14] px-3 py-2 text-[13px] text-slate-200 transition-colors hover:bg-ink/[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>
|
|
);
|
|
}
|