Initial commit: webmail client (M0/M1)
Three-pane webmail client. - Backend: FastAPI + async SQLAlchemy + arq worker (Postgres/Redis), IMAP/SMTP sync, threading, search, drafts/outbox with undo, attachments, 2FA. - Frontend: React 18 + Vite + TypeScript + TanStack Query + Tailwind; three-pane UI with rich-text composer, light/dark themes, resizable panels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
// Back-compat shim: folder icons are now part of the generic Icon component.
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
export function FolderIcon({ name }: { name: string }) {
|
||||
return <Icon name={name} size={18} />;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Inline SVG icons (user-provided SVGs in frontend/icons/). They draw with
|
||||
// `currentColor` but pin a fixed color via an inline `style`; we strip that and
|
||||
// let the size come from CSS so each glyph inherits the surrounding text color
|
||||
// and adapts to light/dark themes.
|
||||
import archive from "../../icons/archive.svg?raw";
|
||||
import compose from "../../icons/compose.svg?raw";
|
||||
import drafts from "../../icons/drafts-28-regular.svg?raw";
|
||||
import forward from "../../icons/forward.svg?raw";
|
||||
import inbox from "../../icons/inbox.svg?raw";
|
||||
import pin from "../../icons/pin.svg?raw";
|
||||
import replyAll from "../../icons/reply-all-line.svg?raw";
|
||||
import reply from "../../icons/reply.svg?raw";
|
||||
import sent from "../../icons/sent-02.svg?raw";
|
||||
import spam from "../../icons/spam.svg?raw";
|
||||
import trash from "../../icons/trash.svg?raw";
|
||||
|
||||
const RAW: Record<string, string> = {
|
||||
archive,
|
||||
compose,
|
||||
drafts,
|
||||
forward,
|
||||
inbox,
|
||||
pin,
|
||||
reply,
|
||||
"reply-all": replyAll,
|
||||
sent,
|
||||
spam,
|
||||
trash,
|
||||
};
|
||||
|
||||
export type IconName = keyof typeof RAW;
|
||||
|
||||
// Drop the baked-in size/color so the glyph fills its box and inherits color.
|
||||
function clean(svg: string): string {
|
||||
return svg
|
||||
.replace(/\sstyle="[^"]*"/i, "")
|
||||
.replace(/\swidth="[^"]*"/i, "")
|
||||
.replace(/\sheight="[^"]*"/i, "");
|
||||
}
|
||||
|
||||
const CLEANED: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(RAW).map(([name, svg]) => [name, clean(svg)]),
|
||||
);
|
||||
|
||||
export function Icon({
|
||||
name,
|
||||
size = 18,
|
||||
className = "",
|
||||
}: {
|
||||
name: IconName | string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={`inline-flex shrink-0 items-center justify-center [&>svg]:h-full [&>svg]:w-full ${className}`}
|
||||
style={{ width: size, height: size }}
|
||||
dangerouslySetInnerHTML={{ __html: CLEANED[name] ?? "" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// A vertical drag handle for adjusting the width of the panel to its left.
|
||||
export function Resizer({ onDelta }: { onDelta: (deltaX: number) => void }) {
|
||||
const onMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
let lastX = e.clientX;
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
onDelta(ev.clientX - lastX);
|
||||
lastX = ev.clientX;
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
document.body.style.userSelect = "none";
|
||||
document.body.style.cursor = "col-resize";
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseDown={onMouseDown}
|
||||
className="group relative z-20 -mx-0.5 w-1 shrink-0 cursor-col-resize"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
>
|
||||
<div className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-transparent group-hover:bg-app-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useT } from "../i18n";
|
||||
import { useTheme } from "../lib/theme";
|
||||
|
||||
// Quick light/dark flip (the full light/dark/system selector lives in Settings).
|
||||
export function ThemeToggle() {
|
||||
const t = useT();
|
||||
const { resolved, setTheme } = useTheme();
|
||||
return (
|
||||
<button
|
||||
className="rounded p-2 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
title={t("theme.toggle")}
|
||||
aria-label={t("theme.toggle")}
|
||||
onClick={() => setTheme(resolved === "dark" ? "light" : "dark")}
|
||||
>
|
||||
{resolved === "dark" ? "☀" : "☾"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import type { AccountOut, AttachmentOut, DraftInput, DraftOut } from "../../lib/types";
|
||||
import { Icon } from "../Icon";
|
||||
import { RecipientChips } from "./RecipientChips";
|
||||
import { RichTextEditor } from "./RichTextEditor";
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB per file
|
||||
|
||||
export interface ComposerInit {
|
||||
accountId: string;
|
||||
threadId?: string | null;
|
||||
replyToMessageId?: string | null;
|
||||
kind: string; // reply | reply_all | forward | new
|
||||
to?: string;
|
||||
cc?: string;
|
||||
subject?: string;
|
||||
text?: string;
|
||||
html?: string; // initial body when reopening an existing draft
|
||||
draftId?: string; // when reopening an existing draft
|
||||
attachmentIds?: string[]; // compose-attachment ids when reopening a draft
|
||||
// A reply/forward quotes the original: this attribution line sits above the
|
||||
// quoted message body (fetched from the original message's sanitized HTML).
|
||||
quote?: { attribution: string; messageId: string };
|
||||
}
|
||||
|
||||
// Build the composer seed from a saved draft (used to reopen it in place or in a
|
||||
// popped-out window).
|
||||
export function draftToInit(draft: DraftOut): ComposerInit {
|
||||
return {
|
||||
accountId: draft.account_id,
|
||||
threadId: draft.thread_id,
|
||||
replyToMessageId: draft.reply_to_message_id,
|
||||
kind: draft.kind,
|
||||
to: draft.payload.to.join(", "),
|
||||
cc: draft.payload.cc.join(", "),
|
||||
subject: draft.payload.subject,
|
||||
text: draft.payload.text,
|
||||
html: draft.payload.html || undefined,
|
||||
attachmentIds: draft.payload.attachment_ids,
|
||||
draftId: draft.id,
|
||||
};
|
||||
}
|
||||
|
||||
function splitList(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function htmlToText(html: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = html;
|
||||
return div.textContent ?? "";
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function textToHtml(text: string): string {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => `<div>${escapeHtml(line) || "<br>"}</div>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// A single header icon-button (Attach / window controls / etc.).
|
||||
function HeaderButton({
|
||||
onClick,
|
||||
title,
|
||||
active = false,
|
||||
children,
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
title: string;
|
||||
active?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-1 rounded-md px-2 py-1.5 text-sm ${
|
||||
active ? "bg-app-active text-app-fg" : "text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const caret = (
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 6l4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function Composer({
|
||||
init,
|
||||
onClose,
|
||||
expanded = false,
|
||||
onToggleExpand,
|
||||
fullWindow = false,
|
||||
}: {
|
||||
init: ComposerInit;
|
||||
onClose: () => void;
|
||||
expanded?: boolean;
|
||||
onToggleExpand?: () => void;
|
||||
fullWindow?: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
||||
const accounts: AccountOut[] = accountsQuery.data ?? [];
|
||||
|
||||
const [accountId, setAccountId] = useState(init.accountId);
|
||||
const [to, setTo] = useState(init.to ?? "");
|
||||
const [cc, setCc] = useState(init.cc ?? "");
|
||||
const [bcc, setBcc] = useState("");
|
||||
const [showCc, setShowCc] = useState(Boolean(init.cc));
|
||||
const [subject, setSubject] = useState(init.subject ?? "");
|
||||
const [bodyHtml, setBodyHtml] = useState(init.html ?? "");
|
||||
const [quoteHtml, setQuoteHtml] = useState("");
|
||||
const [quoteResolved, setQuoteResolved] = useState(!init.quote);
|
||||
const [showToolbar, setShowToolbar] = useState(true);
|
||||
const [fromOpen, setFromOpen] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [attachments, setAttachments] = useState<AttachmentOut[]>([]);
|
||||
const [attachError, setAttachError] = useState("");
|
||||
const [status, setStatus] = useState<"" | "saving" | "saved" | "sending" | "error">("");
|
||||
const [undo, setUndo] = useState<{ outboxId: string } | null>(null);
|
||||
const draftId = useRef<string | null>(init.draftId ?? null);
|
||||
const undoTimer = useRef<number | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const account = accounts.find((a) => a.id === accountId);
|
||||
const fromName = account?.display_name || "";
|
||||
const fromEmail = account?.email ?? "";
|
||||
|
||||
// Fetch the original message's sanitized body to quote in a reply/forward.
|
||||
useEffect(() => {
|
||||
const quote = init.quote;
|
||||
if (!quote) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await fetch(api.messageHtmlUrl(quote.messageId), { credentials: "include" });
|
||||
const doc = new DOMParser().parseFromString(await resp.text(), "text/html");
|
||||
if (!cancelled) setQuoteHtml(doc.body?.innerHTML ?? "");
|
||||
} catch {
|
||||
/* leave the quote empty on failure */
|
||||
} finally {
|
||||
if (!cancelled) setQuoteResolved(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [init.quote]);
|
||||
|
||||
// Resolve attachment metadata when reopening a draft that already has some.
|
||||
useEffect(() => {
|
||||
const ids = init.attachmentIds ?? [];
|
||||
if (ids.length === 0) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const rows = await api.composeAttachments(ids);
|
||||
if (!cancelled) setAttachments(rows);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const addFiles = async (files: FileList) => {
|
||||
setAttachError("");
|
||||
for (const file of Array.from(files)) {
|
||||
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||||
setAttachError(t("compose.attachTooLarge"));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const att = await api.uploadComposeAttachment(file);
|
||||
setAttachments((prev) => [...prev, att]);
|
||||
} catch (e) {
|
||||
setAttachError(e instanceof ApiError ? e.message : t("compose.attachTooLarge"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeAttachment = (id: string) =>
|
||||
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||
|
||||
// The editor's seed content: an empty line to type into, then (for replies)
|
||||
// the attribution line and the quoted original inside a blockquote.
|
||||
const initialHtml = useMemo(() => {
|
||||
if (init.html) return init.html;
|
||||
if (init.text) return textToHtml(init.text);
|
||||
if (init.quote) {
|
||||
return (
|
||||
`<div><br></div>` +
|
||||
`<div>${escapeHtml(init.quote.attribution)}</div>` +
|
||||
`<blockquote>${quoteHtml}</blockquote>`
|
||||
);
|
||||
}
|
||||
return `<div><br></div>`;
|
||||
// Only the quote resolving should (re)compute this; it mounts the editor once.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [quoteResolved]);
|
||||
|
||||
const bodyReady = quoteResolved;
|
||||
|
||||
const payload = (): DraftInput => {
|
||||
const html = bodyHtml || initialHtml;
|
||||
return {
|
||||
account_id: accountId,
|
||||
thread_id: init.threadId ?? null,
|
||||
reply_to_message_id: init.replyToMessageId ?? null,
|
||||
kind: init.kind,
|
||||
payload: {
|
||||
to: splitList(to),
|
||||
cc: splitList(cc),
|
||||
bcc: splitList(bcc),
|
||||
subject,
|
||||
html,
|
||||
text: htmlToText(html),
|
||||
attachment_ids: attachments.map((a) => a.id),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (undo) return;
|
||||
const handle = window.setTimeout(async () => {
|
||||
if (!to && !subject && !bodyHtml) return;
|
||||
setStatus("saving");
|
||||
try {
|
||||
if (draftId.current) {
|
||||
await api.updateDraft(draftId.current, payload());
|
||||
} else {
|
||||
draftId.current = (await api.createDraft(payload())).id;
|
||||
}
|
||||
setStatus("saved");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, 2000);
|
||||
return () => window.clearTimeout(handle);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [accountId, to, cc, bcc, subject, bodyHtml, attachments]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (undoTimer.current) window.clearTimeout(undoTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const send = async () => {
|
||||
setStatus("sending");
|
||||
try {
|
||||
if (!draftId.current) draftId.current = (await api.createDraft(payload())).id;
|
||||
else await api.updateDraft(draftId.current, payload());
|
||||
const res = await api.send(draftId.current);
|
||||
setUndo({ outboxId: res.outbox_id });
|
||||
undoTimer.current = window.setTimeout(onClose, 10000);
|
||||
} catch (e) {
|
||||
setStatus("error");
|
||||
if (!(e instanceof ApiError)) throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const doUndo = async () => {
|
||||
if (!undo) return;
|
||||
try {
|
||||
await api.undoSend(undo.outboxId);
|
||||
} catch {
|
||||
/* too late */
|
||||
}
|
||||
if (undoTimer.current) window.clearTimeout(undoTimer.current);
|
||||
setUndo(null);
|
||||
setStatus("");
|
||||
};
|
||||
|
||||
const discard = async () => {
|
||||
if (draftId.current) {
|
||||
try {
|
||||
await api.deleteDraft(draftId.current);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
draftId.current = null;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const popOut = async () => {
|
||||
// Persist first so the new window can load this exact draft.
|
||||
try {
|
||||
if (!draftId.current) draftId.current = (await api.createDraft(payload())).id;
|
||||
else await api.updateDraft(draftId.current, payload());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// A window a quarter of the screen area (half width × half height), centered.
|
||||
const w = Math.round(window.screen.width / 2);
|
||||
const h = Math.round(window.screen.height / 2);
|
||||
const left = Math.round((window.screen.width - w) / 2);
|
||||
const top = Math.round((window.screen.height - h) / 2);
|
||||
window.open(
|
||||
`/compose/${draftId.current}`,
|
||||
`compose-${draftId.current}`,
|
||||
`width=${w},height=${h},left=${left},top=${top}`,
|
||||
);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (undo) {
|
||||
return (
|
||||
<div className="m-3 flex items-center justify-between rounded-lg bg-app-fg px-4 py-2.5 text-sm text-app-surface shadow-lg">
|
||||
<span>{t("compose.sent")}</span>
|
||||
<button className="font-medium text-app-accent" onClick={doUndo}>
|
||||
{t("compose.undo")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col overflow-hidden border-app-border bg-app-elevated ${
|
||||
fullWindow
|
||||
? "h-full"
|
||||
: `rounded-xl border shadow-2xl ${expanded ? "h-full" : ""}`
|
||||
}`}
|
||||
>
|
||||
{/* Header: Send · Attach · formatting toggle · More · window controls */}
|
||||
<div className="flex items-center gap-1.5 border-b border-app-border bg-app-surface px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={send}
|
||||
disabled={status === "sending" || (!to && !cc)}
|
||||
className="flex items-center gap-2 rounded-md bg-app-accent px-3 py-1.5 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14.5 1.5L7.2 8.8" />
|
||||
<path d="M14.5 1.5L9.8 14.5 7.2 8.8 1.5 6.2 14.5 1.5z" />
|
||||
</svg>
|
||||
{status === "sending" ? t("compose.sending") : t("compose.send")}
|
||||
</button>
|
||||
<HeaderButton onClick={() => fileInputRef.current?.click()} title={t("compose.attach")}>
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12.5 6.5l-5.2 5.2a2.5 2.5 0 0 1-3.5-3.5l5.4-5.4a1.6 1.6 0 0 1 2.3 2.3l-5.3 5.3a0.8 0.8 0 0 1-1.1-1.1l4.9-4.9" />
|
||||
</svg>
|
||||
{t("compose.attach")}
|
||||
</HeaderButton>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files && e.target.files.length) void addFiles(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<HeaderButton
|
||||
onClick={() => setShowToolbar((v) => !v)}
|
||||
title={t("compose.fmtBold")}
|
||||
active={showToolbar}
|
||||
>
|
||||
<span className="text-sm font-semibold underline">A</span>
|
||||
</HeaderButton>
|
||||
<div className="relative">
|
||||
<HeaderButton onClick={() => setMoreOpen((v) => !v)} title={t("compose.more")}>
|
||||
{t("compose.more")}
|
||||
</HeaderButton>
|
||||
{moreOpen && (
|
||||
<div className="absolute left-0 top-full z-30 mt-1 w-44 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 text-sm shadow-xl">
|
||||
<button
|
||||
className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
setShowCc(true);
|
||||
}}
|
||||
>
|
||||
{t("compose.cc")} / {t("compose.bcc")}
|
||||
</button>
|
||||
<button
|
||||
className="block w-full px-3 py-1.5 text-left text-red-500 hover:bg-app-hover"
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
void discard();
|
||||
}}
|
||||
>
|
||||
{t("compose.discard")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1 text-xs text-app-faint">
|
||||
{status === "saving" && <span className="mr-1">{t("compose.savingDraft")}</span>}
|
||||
{status === "saved" && <span className="mr-1">{t("compose.draftSaved")}</span>}
|
||||
{status === "error" && <span className="mr-1 text-red-500">{t("compose.sendFailed")}</span>}
|
||||
{!fullWindow && onToggleExpand && (
|
||||
<HeaderButton
|
||||
onClick={onToggleExpand}
|
||||
title={expanded ? t("compose.collapse") : t("compose.expand")}
|
||||
active={expanded}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 2H2v4M10 2h4v4M6 14H2v-4M10 14h4v-4" />
|
||||
</svg>
|
||||
</HeaderButton>
|
||||
)}
|
||||
{!fullWindow && (
|
||||
<HeaderButton onClick={popOut} title={t("compose.popOut")}>
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 3H3v10h10v-3M9 3h4v4M13 3L7 9" />
|
||||
</svg>
|
||||
</HeaderButton>
|
||||
)}
|
||||
<HeaderButton onClick={onClose} title={t("common.close")}>
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
</HeaderButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* From */}
|
||||
<div className="relative flex items-center gap-2 border-b border-app-border px-3 py-2">
|
||||
<span className="w-10 shrink-0 text-xs font-semibold text-app-fg">{t("compose.from")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => accounts.length > 1 && setFromOpen((v) => !v)}
|
||||
className="flex items-center gap-1.5 rounded px-1 py-0.5 text-sm hover:bg-app-hover"
|
||||
>
|
||||
{fromName && <span className="font-medium text-app-accent">{fromName}</span>}
|
||||
<span className="text-app-muted">{fromName ? `<${fromEmail}>` : fromEmail}</span>
|
||||
{accounts.length > 1 && <span className="text-app-faint">{caret}</span>}
|
||||
</button>
|
||||
<div className="ml-auto flex items-center gap-1 text-xs text-app-muted">
|
||||
<Icon name="reply" size={13} />
|
||||
{t("compose.replyLabel")}
|
||||
</div>
|
||||
{fromOpen && (
|
||||
<div className="absolute left-12 top-full z-30 mt-1 w-80 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 shadow-xl">
|
||||
{accounts.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-app-hover"
|
||||
onClick={() => {
|
||||
setAccountId(a.id);
|
||||
setFromOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: a.color }} />
|
||||
{a.display_name && <span className="font-medium text-app-fg">{a.display_name}</span>}
|
||||
<span className="text-app-muted">{a.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recipients */}
|
||||
<RecipientChips
|
||||
label={t("compose.to")}
|
||||
value={to}
|
||||
onChange={setTo}
|
||||
autoFocus={init.kind === "new" || init.kind === "forward"}
|
||||
rightSlot={
|
||||
!showCc ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-app-muted hover:text-app-fg"
|
||||
onClick={() => setShowCc(true)}
|
||||
>
|
||||
{t("compose.cc")} {t("compose.bcc")}
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{showCc && (
|
||||
<>
|
||||
<RecipientChips label={t("compose.cc")} value={cc} onChange={setCc} />
|
||||
<RecipientChips label={t("compose.bcc")} value={bcc} onChange={setBcc} />
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
className="border-b border-app-border bg-transparent px-3 py-2 text-sm text-app-fg outline-none placeholder:text-app-faint"
|
||||
placeholder={t("compose.subject")}
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
/>
|
||||
|
||||
{(attachments.length > 0 || attachError) && (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-app-border px-3 py-2">
|
||||
{attachments.map((att) => (
|
||||
<span
|
||||
key={att.id}
|
||||
className="flex items-center gap-1.5 rounded border border-app-border bg-app-hover px-2 py-1 text-xs text-app-fg"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12.5 6.5l-5.2 5.2a2.5 2.5 0 0 1-3.5-3.5l5.4-5.4a1.6 1.6 0 0 1 2.3 2.3l-5.3 5.3a0.8 0.8 0 0 1-1.1-1.1l4.9-4.9" />
|
||||
</svg>
|
||||
<span className="max-w-[12rem] truncate">{att.filename}</span>
|
||||
<span className="text-app-faint">{formatBytes(att.size_bytes)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAttachment(att.id)}
|
||||
className="text-app-faint hover:text-app-fg"
|
||||
aria-label={`remove ${att.filename}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{attachError && <span className="text-xs text-red-500">{attachError}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
{bodyReady ? (
|
||||
<RichTextEditor initialHtml={initialHtml} onInput={setBodyHtml} showToolbar={showToolbar} />
|
||||
) : (
|
||||
<div className="min-h-[250px] flex-1 px-3 py-3 text-sm text-app-faint">…</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { api } from "../../lib/api";
|
||||
import type { RecipientSuggestion } from "../../lib/types";
|
||||
|
||||
function splitList(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseAddr(token: string): { name: string; email: string } {
|
||||
const m = token.match(/^(.*?)\s*<([^>]+)>$/);
|
||||
if (m) return { name: m[1].trim(), email: m[2].trim() };
|
||||
return { name: "", email: token };
|
||||
}
|
||||
|
||||
// A recipient field rendering committed addresses as removable chips with an
|
||||
// inline autocomplete input (Fastmail-style). The value stays a comma-joined
|
||||
// string ("Name <email>" or bare email) so the draft payload is unchanged.
|
||||
export function RecipientChips({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
rightSlot,
|
||||
autoFocus,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
rightSlot?: React.ReactNode;
|
||||
autoFocus?: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
const chips = splitList(value);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<RecipientSuggestion[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const commit = (token: string) => {
|
||||
const tok = token.trim().replace(/,+$/, "").trim();
|
||||
if (!tok) return;
|
||||
onChange([...chips, tok].join(", "));
|
||||
setDraft("");
|
||||
setOpen(false);
|
||||
setSuggestions([]);
|
||||
};
|
||||
const removeAt = (i: number) => onChange(chips.filter((_, idx) => idx !== i).join(", "));
|
||||
|
||||
useEffect(() => {
|
||||
if (draft.trim().length < 2) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const handle = window.setTimeout(async () => {
|
||||
try {
|
||||
const results = await api.suggestRecipients(draft.trim());
|
||||
if (!cancelled) {
|
||||
setSuggestions(results);
|
||||
setOpen(results.length > 0);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 150);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(handle);
|
||||
};
|
||||
}, [draft]);
|
||||
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onClick);
|
||||
return () => document.removeEventListener("mousedown", onClick);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={boxRef} className="relative flex items-start gap-2 border-b border-app-border px-3 py-1.5">
|
||||
<label className="mt-1.5 w-10 shrink-0 text-xs font-semibold text-app-fg">{label}</label>
|
||||
<div className="flex flex-1 flex-wrap items-center gap-1.5 py-0.5">
|
||||
{chips.map((chip, i) => {
|
||||
const { name, email } = parseAddr(chip);
|
||||
return (
|
||||
<span
|
||||
key={`${chip}-${i}`}
|
||||
className="flex items-center gap-1 rounded border border-app-border bg-app-hover px-2 py-0.5 text-xs"
|
||||
>
|
||||
{name && <span className="font-medium text-app-fg">{name}</span>}
|
||||
<span className="text-app-muted">{name ? `<${email}>` : email}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAt(i)}
|
||||
className="ml-0.5 text-app-faint hover:text-app-fg"
|
||||
aria-label={`remove ${email}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<input
|
||||
className="min-w-[8rem] flex-1 bg-transparent py-1 text-sm text-app-fg outline-none placeholder:text-app-faint"
|
||||
value={draft}
|
||||
placeholder={chips.length === 0 ? t("compose.addRecipient") : ""}
|
||||
autoFocus={autoFocus}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
commit(draft);
|
||||
} else if (e.key === "Backspace" && draft === "" && chips.length > 0) {
|
||||
removeAt(chips.length - 1);
|
||||
}
|
||||
}}
|
||||
onBlur={() => draft.trim() && commit(draft)}
|
||||
onFocus={() => setOpen(suggestions.length > 0)}
|
||||
/>
|
||||
</div>
|
||||
{rightSlot && <div className="mt-1 flex shrink-0 items-center gap-2">{rightSlot}</div>}
|
||||
{open && (
|
||||
<ul className="absolute left-12 top-full z-30 mt-1 max-h-56 w-80 overflow-y-auto rounded-lg border border-app-border bg-app-elevated shadow-xl">
|
||||
{suggestions.map((s) => (
|
||||
<li key={s.email}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full flex-col px-3 py-1.5 text-left hover:bg-app-hover"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
commit(s.name ? `${s.name} <${s.email}>` : s.email);
|
||||
}}
|
||||
>
|
||||
{s.name && <span className="text-sm text-app-fg">{s.name}</span>}
|
||||
<span className="text-xs text-app-muted">{s.email}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
// A lightweight WYSIWYG editor over a contenteditable div. The DOM is the source
|
||||
// of truth (uncontrolled) so the caret never jumps; we read innerHTML on input.
|
||||
// Formatting uses document.execCommand — deprecated but universally supported and
|
||||
// the pragmatic choice for a compose surface of this size.
|
||||
|
||||
type Cmd =
|
||||
| "bold"
|
||||
| "italic"
|
||||
| "underline"
|
||||
| "strikeThrough"
|
||||
| "insertUnorderedList"
|
||||
| "insertOrderedList"
|
||||
| "justifyLeft"
|
||||
| "justifyCenter"
|
||||
| "justifyRight";
|
||||
|
||||
const STATE_CMDS: Cmd[] = [
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
"strikeThrough",
|
||||
"insertUnorderedList",
|
||||
"insertOrderedList",
|
||||
"justifyLeft",
|
||||
"justifyCenter",
|
||||
"justifyRight",
|
||||
];
|
||||
|
||||
// A fixed swatch palette (10 columns × 8 rows, row-major): a grayscale top row,
|
||||
// then a saturated hue row followed by tints (lighter) and shades (darker).
|
||||
const PALETTE: string[] = [
|
||||
"#000000", "#434343", "#666666", "#999999", "#b7b7b7", "#cccccc", "#d9d9d9", "#efefef", "#f3f3f3", "#ffffff",
|
||||
"#980000", "#ff0000", "#ff9900", "#ffff00", "#00ff00", "#00ffff", "#4a86e8", "#0000ff", "#9900ff", "#ff00ff",
|
||||
"#e6b8af", "#f4cccc", "#fce5cd", "#fff2cc", "#d9ead3", "#d0e0e3", "#c9daf8", "#cfe2f3", "#d9d2e9", "#ead1dc",
|
||||
"#dd7e6b", "#ea9999", "#f9cb9c", "#ffe599", "#b6d7a8", "#a2c4c9", "#a4c2f4", "#9fc5e8", "#b4a7d6", "#d5a6bd",
|
||||
"#cc4125", "#e06666", "#f6b26b", "#ffd966", "#93c47d", "#76a5af", "#6d9eeb", "#6fa8dc", "#8e7cc3", "#c27ba0",
|
||||
"#a61c00", "#cc0000", "#e69138", "#f1c232", "#6aa84f", "#45818e", "#3c78d8", "#3d85c6", "#674ea7", "#a64d79",
|
||||
"#85200c", "#990000", "#b45f06", "#bf9000", "#38761d", "#134f5c", "#1155cc", "#0b5394", "#351c75", "#741b47",
|
||||
"#5b0f00", "#660000", "#783f04", "#7f6000", "#274e13", "#0c343d", "#1c4587", "#073763", "#20124d", "#4c1130",
|
||||
];
|
||||
|
||||
function Svg({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const ICONS = {
|
||||
bulletList: (
|
||||
<Svg>
|
||||
<circle cx="3" cy="4" r="0.9" fill="currentColor" stroke="none" />
|
||||
<circle cx="3" cy="8" r="0.9" fill="currentColor" stroke="none" />
|
||||
<circle cx="3" cy="12" r="0.9" fill="currentColor" stroke="none" />
|
||||
<path d="M6 4h8M6 8h8M6 12h8" />
|
||||
</Svg>
|
||||
),
|
||||
numberList: (
|
||||
<Svg>
|
||||
<path d="M6 4h8M6 8h8M6 12h8" />
|
||||
<path d="M2 3v2.5M2 5.5h1" strokeWidth="1.2" />
|
||||
<path d="M1.6 10.5h1.4L1.6 12.5h1.6" strokeWidth="1.2" />
|
||||
</Svg>
|
||||
),
|
||||
quote: (
|
||||
<Svg>
|
||||
<path d="M3 6.5C3 5 4 4.5 5 4.5M3 6.5v3.5h3V6.5H4.6C4.6 5.6 5 5.3 5.4 5.2" />
|
||||
<path d="M9 6.5C9 5 10 4.5 11 4.5M9 6.5v3.5h3V6.5h-1.4C10.6 5.6 11 5.3 11.4 5.2" />
|
||||
</Svg>
|
||||
),
|
||||
link: (
|
||||
<Svg>
|
||||
<path d="M6.5 9.5a2.5 2.5 0 0 0 3.5 0l2-2a2.5 2.5 0 0 0-3.5-3.5l-1 1" />
|
||||
<path d="M9.5 6.5a2.5 2.5 0 0 0-3.5 0l-2 2a2.5 2.5 0 0 0 3.5 3.5l1-1" />
|
||||
</Svg>
|
||||
),
|
||||
alignLeft: (
|
||||
<Svg>
|
||||
<path d="M2 4h12M2 7h8M2 10h11M2 13h7" />
|
||||
</Svg>
|
||||
),
|
||||
alignCenter: (
|
||||
<Svg>
|
||||
<path d="M2 4h12M4 7h8M3 10h10M4 13h8" />
|
||||
</Svg>
|
||||
),
|
||||
alignRight: (
|
||||
<Svg>
|
||||
<path d="M2 4h12M6 7h8M3 10h11M7 13h7" />
|
||||
</Svg>
|
||||
),
|
||||
clear: (
|
||||
<Svg>
|
||||
<path d="M5 4h8M9 4l-2 9M4 13h5" />
|
||||
<path d="M11 11l3 3M14 11l-3 3" strokeWidth="1.3" />
|
||||
</Svg>
|
||||
),
|
||||
highlight: (
|
||||
<Svg>
|
||||
<path d="M4 11.5l4.5-4.5 3 3L7 14.5H4v-3z" />
|
||||
<path d="M8.5 7l2.5-2.5 3 3L11.5 10" />
|
||||
<path d="M3 16.5h7" strokeWidth="1.8" />
|
||||
</Svg>
|
||||
),
|
||||
image: (
|
||||
<Svg>
|
||||
<rect x="2.5" y="3.5" width="11" height="9" rx="1" />
|
||||
<circle cx="6" cy="6.5" r="1" fill="currentColor" stroke="none" />
|
||||
<path d="M3 11l3-2.5 2.5 2 2-1.5L13.5 11" />
|
||||
</Svg>
|
||||
),
|
||||
} as const;
|
||||
|
||||
export function RichTextEditor({
|
||||
initialHtml,
|
||||
onInput,
|
||||
onReady,
|
||||
showToolbar = true,
|
||||
}: {
|
||||
initialHtml: string;
|
||||
onInput: (html: string) => void;
|
||||
onReady?: (el: HTMLDivElement) => void;
|
||||
showToolbar?: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const savedRange = useRef<Range | null>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const paletteRef = useRef<HTMLDivElement>(null);
|
||||
const [active, setActive] = useState<Record<string, boolean>>({});
|
||||
const [colorMenu, setColorMenu] = useState<null | "fore" | "hilite">(null);
|
||||
|
||||
// Seed the editable region exactly once.
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
el.innerHTML = initialHtml;
|
||||
onReady?.(el);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Remember the caret/selection so native pickers (color/file) that steal focus
|
||||
// can restore where formatting should apply.
|
||||
const saveSelection = useCallback(() => {
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0 && ref.current?.contains(sel.anchorNode)) {
|
||||
savedRange.current = sel.getRangeAt(0).cloneRange();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const restoreSelection = () => {
|
||||
const sel = window.getSelection();
|
||||
if (sel && savedRange.current) {
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(savedRange.current);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshActive = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el || document.activeElement !== el) return;
|
||||
saveSelection();
|
||||
const next: Record<string, boolean> = {};
|
||||
for (const cmd of STATE_CMDS) {
|
||||
try {
|
||||
next[cmd] = document.queryCommandState(cmd);
|
||||
} catch {
|
||||
next[cmd] = false;
|
||||
}
|
||||
}
|
||||
setActive(next);
|
||||
}, [saveSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("selectionchange", refreshActive);
|
||||
return () => document.removeEventListener("selectionchange", refreshActive);
|
||||
}, [refreshActive]);
|
||||
|
||||
// Open/close the swatch palette (snapshotting the selection to apply color to).
|
||||
const openColorMenu = (kind: "fore" | "hilite") => {
|
||||
saveSelection();
|
||||
setColorMenu((prev) => (prev === kind ? null : kind));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!colorMenu) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (paletteRef.current && !paletteRef.current.contains(e.target as Node)) {
|
||||
setColorMenu(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [colorMenu]);
|
||||
|
||||
const emit = () => {
|
||||
if (ref.current) onInput(ref.current.innerHTML);
|
||||
};
|
||||
|
||||
const exec = (command: string, value?: string) => {
|
||||
ref.current?.focus();
|
||||
document.execCommand(command, false, value);
|
||||
emit();
|
||||
refreshActive();
|
||||
};
|
||||
|
||||
const link = () => {
|
||||
const url = window.prompt(t("compose.linkPrompt"), "https://");
|
||||
if (url) exec("createLink", url);
|
||||
};
|
||||
|
||||
const applyColor = (kind: "fore" | "hilite", color: string) => {
|
||||
ref.current?.focus();
|
||||
restoreSelection();
|
||||
document.execCommand("styleWithCSS", false, "true");
|
||||
if (kind === "fore") {
|
||||
document.execCommand("foreColor", false, color);
|
||||
} else if (!document.execCommand("hiliteColor", false, color)) {
|
||||
document.execCommand("backColor", false, color);
|
||||
}
|
||||
document.execCommand("styleWithCSS", false, "false");
|
||||
emit();
|
||||
};
|
||||
|
||||
const insertImage = (file: File) => {
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
window.alert(t("compose.imageTooLarge"));
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
ref.current?.focus();
|
||||
restoreSelection();
|
||||
document.execCommand("insertImage", false, String(reader.result));
|
||||
emit();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const Btn = ({
|
||||
onClick,
|
||||
title,
|
||||
isActive = false,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
isActive?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onClick}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded ${
|
||||
isActive ? "bg-app-active text-app-fg" : "text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
const Sep = () => <span className="mx-1 h-5 w-px bg-app-border" />;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
className={`flex-wrap items-center gap-0.5 border-b border-app-border px-2 py-1.5 ${
|
||||
showToolbar ? "flex" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<Btn onClick={() => exec("bold")} title={t("compose.fmtBold")} isActive={active.bold}>
|
||||
<span className="text-sm font-bold">B</span>
|
||||
</Btn>
|
||||
<Btn onClick={() => exec("italic")} title={t("compose.fmtItalic")} isActive={active.italic}>
|
||||
<span className="font-serif text-sm italic">I</span>
|
||||
</Btn>
|
||||
<Btn
|
||||
onClick={() => exec("underline")}
|
||||
title={t("compose.fmtUnderline")}
|
||||
isActive={active.underline}
|
||||
>
|
||||
<span className="text-sm underline">U</span>
|
||||
</Btn>
|
||||
<Btn
|
||||
onClick={() => exec("strikeThrough")}
|
||||
title={t("compose.fmtStrike")}
|
||||
isActive={active.strikeThrough}
|
||||
>
|
||||
<span className="text-sm line-through">S</span>
|
||||
</Btn>
|
||||
<Sep />
|
||||
<Btn
|
||||
onClick={() => exec("insertUnorderedList")}
|
||||
title={t("compose.fmtBulletList")}
|
||||
isActive={active.insertUnorderedList}
|
||||
>
|
||||
{ICONS.bulletList}
|
||||
</Btn>
|
||||
<Btn
|
||||
onClick={() => exec("insertOrderedList")}
|
||||
title={t("compose.fmtNumberList")}
|
||||
isActive={active.insertOrderedList}
|
||||
>
|
||||
{ICONS.numberList}
|
||||
</Btn>
|
||||
<Btn onClick={() => exec("formatBlock", "blockquote")} title={t("compose.fmtQuote")}>
|
||||
{ICONS.quote}
|
||||
</Btn>
|
||||
<Btn onClick={link} title={t("compose.fmtLink")}>
|
||||
{ICONS.link}
|
||||
</Btn>
|
||||
<Sep />
|
||||
<div ref={paletteRef} className="relative flex items-center gap-0.5">
|
||||
<Btn onClick={() => openColorMenu("fore")} title={t("compose.fmtColor")}>
|
||||
<span className="relative flex h-4 w-4 items-center justify-center text-sm font-semibold leading-none">
|
||||
A
|
||||
<span
|
||||
className="absolute -bottom-0.5 left-0 h-[3px] w-full rounded"
|
||||
style={{ background: "#e5484d" }}
|
||||
/>
|
||||
</span>
|
||||
</Btn>
|
||||
<Btn onClick={() => openColorMenu("hilite")} title={t("compose.fmtHighlight")}>
|
||||
{ICONS.highlight}
|
||||
</Btn>
|
||||
{colorMenu && (
|
||||
<div className="absolute left-0 top-full z-40 mt-1 rounded-lg border border-app-border bg-app-elevated p-2 shadow-xl">
|
||||
<div className="grid grid-cols-[repeat(10,1rem)] gap-1">
|
||||
{PALETTE.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
title={color}
|
||||
aria-label={color}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
applyColor(colorMenu, color);
|
||||
setColorMenu(null);
|
||||
}}
|
||||
className="h-4 w-4 rounded-sm border border-black/10 hover:ring-2 hover:ring-app-accent"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Btn
|
||||
onClick={() => {
|
||||
saveSelection();
|
||||
imageInputRef.current?.click();
|
||||
}}
|
||||
title={t("compose.fmtImage")}
|
||||
>
|
||||
{ICONS.image}
|
||||
</Btn>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) insertImage(f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Sep />
|
||||
<Btn
|
||||
onClick={() => exec("justifyLeft")}
|
||||
title={t("compose.fmtAlignLeft")}
|
||||
isActive={active.justifyLeft}
|
||||
>
|
||||
{ICONS.alignLeft}
|
||||
</Btn>
|
||||
<Btn
|
||||
onClick={() => exec("justifyCenter")}
|
||||
title={t("compose.fmtAlignCenter")}
|
||||
isActive={active.justifyCenter}
|
||||
>
|
||||
{ICONS.alignCenter}
|
||||
</Btn>
|
||||
<Btn
|
||||
onClick={() => exec("justifyRight")}
|
||||
title={t("compose.fmtAlignRight")}
|
||||
isActive={active.justifyRight}
|
||||
>
|
||||
{ICONS.alignRight}
|
||||
</Btn>
|
||||
<Sep />
|
||||
<Btn onClick={() => exec("removeFormat")} title={t("compose.fmtClear")}>
|
||||
{ICONS.clear}
|
||||
</Btn>
|
||||
</div>
|
||||
<div
|
||||
ref={ref}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
data-testid="rich-editor"
|
||||
onInput={(e) => onInput((e.target as HTMLDivElement).innerHTML)}
|
||||
onKeyUp={refreshActive}
|
||||
onMouseUp={refreshActive}
|
||||
className="wmc-editor min-h-[250px] flex-1 overflow-y-auto px-3 py-3 text-sm text-app-fg outline-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useT } from "../../i18n";
|
||||
import type { DraftOut } from "../../lib/types";
|
||||
|
||||
function timeLabel(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
if (d.toDateString() === now.toDateString())
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
return d.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
// Local drafts live in the `drafts` table (not an IMAP mailbox), so the Drafts
|
||||
// folder lists them here rather than through the thread list.
|
||||
export function DraftsList({
|
||||
drafts,
|
||||
onOpen,
|
||||
}: {
|
||||
drafts: DraftOut[];
|
||||
onOpen: (draft: DraftOut) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
if (drafts.length === 0) {
|
||||
return <div className="p-6 text-sm text-app-faint">{t("compose.noDrafts")}</div>;
|
||||
}
|
||||
return (
|
||||
<div className="h-full overflow-y-auto" data-testid="drafts-list">
|
||||
{drafts.map((draft) => {
|
||||
const recipients = draft.payload.to.join(", ") || t("compose.noRecipient");
|
||||
const preview = draft.payload.text.trim();
|
||||
return (
|
||||
<button
|
||||
key={draft.id}
|
||||
onClick={() => onOpen(draft)}
|
||||
className="flex w-full flex-col gap-1 border-b border-app-border px-3 py-2.5 text-left hover:bg-app-hover"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-1 truncate text-sm text-app-fg">{recipients}</span>
|
||||
<span className="shrink-0 text-[11px] text-app-faint">{timeLabel(draft.updated_at)}</span>
|
||||
</div>
|
||||
<div className="truncate text-[13px] font-medium text-app-fg">
|
||||
{draft.payload.subject || t("compose.noSubject")}
|
||||
</div>
|
||||
{preview && <div className="truncate text-xs text-app-faint">{preview}</div>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useRef } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import type { AccountOut, ThreadSummary } from "../../lib/types";
|
||||
import { Icon } from "../Icon";
|
||||
|
||||
function timeLabel(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
if (d.toDateString() === now.toDateString())
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
if (d.getFullYear() === now.getFullYear())
|
||||
return d.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
return d.toLocaleDateString([], { year: "2-digit", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
// Virtualized thread list (SPEC §2 list/MessageList.tsx).
|
||||
export function MessageList({
|
||||
threads,
|
||||
accounts,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
threads: ThreadSummary[];
|
||||
accounts: AccountOut[];
|
||||
selectedId?: string;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const colorFor = (id: string) => accounts.find((a) => a.id === id)?.color ?? "#888888";
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: threads.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 82,
|
||||
overscan: 8,
|
||||
});
|
||||
|
||||
if (threads.length === 0) {
|
||||
return <div className="p-6 text-sm text-app-faint">{t("mail.noConversations")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="h-full overflow-y-auto" data-testid="message-list">
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: "relative", width: "100%" }}>
|
||||
{virtualizer.getVirtualItems().map((item) => {
|
||||
const thread = threads[item.index];
|
||||
const unread = thread.unread_count > 0;
|
||||
const selected = selectedId === thread.id;
|
||||
return (
|
||||
<button
|
||||
key={thread.id}
|
||||
onClick={() => onSelect(thread.id)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: item.size,
|
||||
transform: `translateY(${item.start}px)`,
|
||||
}}
|
||||
className={`flex flex-col gap-1 border-b border-app-border px-3 py-2.5 text-left ${
|
||||
selected
|
||||
? "bg-app-active"
|
||||
: "hover:bg-app-hover"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{unread ? (
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-app-accent" />
|
||||
) : (
|
||||
<span className="h-2 w-2 shrink-0" />
|
||||
)}
|
||||
<span className="flex shrink-0 gap-0.5">
|
||||
{thread.account_ids.map((id) => (
|
||||
<span
|
||||
key={id}
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: colorFor(id) }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className={`flex-1 truncate text-sm ${unread ? "font-semibold text-app-fg" : "text-app-fg"}`}>
|
||||
{thread.participants.map((p) => p.name || p.email).join(", ") || "(no one)"}
|
||||
</span>
|
||||
{thread.message_count > 1 && (
|
||||
<span className="shrink-0 rounded-full bg-app-hover px-1.5 text-[11px] text-app-muted">
|
||||
{thread.message_count}
|
||||
</span>
|
||||
)}
|
||||
{thread.pinned && (
|
||||
<Icon name="pin" size={13} className="text-[rgb(220_129_143)]" />
|
||||
)}
|
||||
<span className="shrink-0 text-[11px] text-app-faint">{timeLabel(thread.last_message_at)}</span>
|
||||
</div>
|
||||
<div className={`truncate pl-4 text-[13px] ${unread ? "font-medium text-app-fg" : "text-app-muted"}`}>
|
||||
{thread.subject}
|
||||
{thread.has_attachments && <span className="ml-1 text-app-faint">📎</span>}
|
||||
</div>
|
||||
<div className="truncate pl-4 text-xs text-app-faint">{thread.snippet}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { api } from "../../lib/api";
|
||||
import type { AttachmentOut } from "../../lib/types";
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function FileGlyph() {
|
||||
return (
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M6 2.5h7L19 8v13a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 5 21V4A1.5 1.5 0 0 1 6 2.5z"
|
||||
fill="currentColor"
|
||||
opacity="0.15"
|
||||
/>
|
||||
<path
|
||||
d="M13 2.5L19 8h-4.5A1.5 1.5 0 0 1 13 6.5V2.5z"
|
||||
fill="currentColor"
|
||||
opacity="0.4"
|
||||
/>
|
||||
<path d="M8 12.5h8M8 15.5h8M8 18.5h5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadGlyph() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M8 2v8M5 7l3 3 3-3M3 13h10" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Attachments({ messageId }: { messageId: string }) {
|
||||
const t = useT();
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["attachments", messageId],
|
||||
queryFn: () => api.messageAttachments(messageId),
|
||||
});
|
||||
|
||||
const attachments = data ?? [];
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const selectedIds = [...selected];
|
||||
|
||||
return (
|
||||
<div className="border-t border-app-border px-3 py-3">
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<a
|
||||
href={api.attachmentsZipUrl(messageId, selectedIds)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-app-border px-3 py-1.5 text-xs text-app-fg hover:bg-app-hover"
|
||||
>
|
||||
<DownloadGlyph />
|
||||
{t("mail.downloadZip")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attachments.map((att: AttachmentOut) => (
|
||||
<div
|
||||
key={att.id}
|
||||
className="flex min-w-[13rem] max-w-[18rem] items-center gap-2 rounded-md border border-app-border bg-app-surface px-2.5 py-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(att.id)}
|
||||
onChange={() => toggle(att.id)}
|
||||
className="h-3.5 w-3.5 shrink-0 accent-app-accent"
|
||||
aria-label={att.filename}
|
||||
/>
|
||||
<span className="shrink-0 text-app-accent">
|
||||
<FileGlyph />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-app-fg" title={att.filename}>
|
||||
{att.filename}
|
||||
</span>
|
||||
<span className="block text-xs text-app-faint">{formatSize(att.size_bytes)}</span>
|
||||
</span>
|
||||
<a
|
||||
href={api.attachmentDownloadUrl(messageId, att.id)}
|
||||
title={t("mail.download")}
|
||||
aria-label={`${t("mail.download")} ${att.filename}`}
|
||||
className="shrink-0 rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
>
|
||||
<DownloadGlyph />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<a
|
||||
href={api.attachmentsZipUrl(messageId)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-app-hover px-3 py-1.5 text-xs text-app-fg hover:bg-app-active"
|
||||
>
|
||||
<DownloadGlyph />
|
||||
{t("mail.downloadAll", { count: attachments.length })}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Composer anchor slot (SPEC §9.4). M0: a reply placeholder attached to the
|
||||
// newest message. The single placement rule ("composer is always attached to the
|
||||
// newest message") lives in ThreadView; this is the slot it renders.
|
||||
|
||||
export function Composer({ subject }: { subject: string }) {
|
||||
return (
|
||||
<div
|
||||
data-testid="composer-anchor"
|
||||
className="my-2 rounded border border-dashed border-gray-300 bg-gray-50 p-3 text-sm text-gray-500"
|
||||
>
|
||||
Reply to “{subject}” — composer (M1)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { api } from "../../lib/api";
|
||||
import type { MessageEnvelope } from "../../lib/types";
|
||||
import { Attachments } from "./Attachments";
|
||||
|
||||
// Sanitized message HTML in a sandboxed iframe (SPEC §9.4, I-8). We use
|
||||
// sandbox="allow-same-origin" WITHOUT allow-scripts: scripts still never run
|
||||
// (the content is nh3-sanitized and script-less), but same-origin lets us (a)
|
||||
// measure the content to auto-size the frame — so the message shows in full and
|
||||
// only the thread panel scrolls — and (b) send the session cookie so inline
|
||||
// (cid:) images authenticate and load.
|
||||
export function MessageBody({ message }: { message: MessageEnvelope }) {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
const [allowed, setAllowed] = useState(false);
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
const [height, setHeight] = useState(80);
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["message-html", message.id, allowed],
|
||||
queryFn: async () => {
|
||||
const resp = await fetch(`/api/messages/${message.id}/html`, { credentials: "include" });
|
||||
if (!resp.ok) throw new Error("failed to load message");
|
||||
return resp.text();
|
||||
},
|
||||
});
|
||||
|
||||
const resize = useCallback(() => {
|
||||
const doc = frameRef.current?.contentDocument;
|
||||
if (doc?.documentElement) setHeight(doc.documentElement.scrollHeight);
|
||||
}, []);
|
||||
|
||||
const onLoad = () => {
|
||||
const doc = frameRef.current?.contentDocument;
|
||||
if (!doc) return;
|
||||
resize();
|
||||
// Re-measure as images finish loading (they change the content height).
|
||||
doc.querySelectorAll("img").forEach((img) => {
|
||||
if (!img.complete) img.addEventListener("load", resize, { once: true });
|
||||
});
|
||||
observerRef.current?.disconnect();
|
||||
const ro = new ResizeObserver(resize);
|
||||
if (doc.body) ro.observe(doc.body);
|
||||
observerRef.current = ro;
|
||||
};
|
||||
|
||||
useEffect(() => () => observerRef.current?.disconnect(), []);
|
||||
|
||||
const loadImages = async (persist: boolean) => {
|
||||
if (persist) {
|
||||
await api.allowRemoteImages(message.id);
|
||||
queryClient.invalidateQueries({ queryKey: ["message-html", message.id] });
|
||||
}
|
||||
setAllowed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{message.has_remote_images && !allowed && (
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-app-border bg-amber-100 px-3 py-1.5 text-xs text-amber-900 dark:bg-amber-900/30 dark:text-amber-200">
|
||||
<span>{t("mail.imagesBlocked")}</span>
|
||||
<button className="font-medium text-app-accent hover:underline" onClick={() => loadImages(false)}>
|
||||
{t("mail.loadImages")}
|
||||
</button>
|
||||
<button className="font-medium text-app-accent hover:underline" onClick={() => loadImages(true)}>
|
||||
{t("mail.alwaysFromSender")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && <div className="p-3 text-sm text-app-faint">{t("mail.loadingThread")}</div>}
|
||||
{isError && <div className="p-3 text-sm text-red-500">{t("mail.loadFailed")}</div>}
|
||||
{data !== undefined && !isLoading && (
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
title={`message-${message.id}`}
|
||||
sandbox="allow-same-origin"
|
||||
srcDoc={data}
|
||||
onLoad={onLoad}
|
||||
style={{ height }}
|
||||
className="w-full border-0 bg-white"
|
||||
/>
|
||||
)}
|
||||
{message.has_attachments && <Attachments messageId={message.id} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import type { MessageEnvelope } from "../../lib/types";
|
||||
import { Composer, type ComposerInit } from "../compose/Composer";
|
||||
import { Icon } from "../Icon";
|
||||
import { MessageBody } from "./MessageBody";
|
||||
|
||||
// Direction-agnostic thread view (invariant I-5 / SPEC §9.4).
|
||||
// `messages` is ALWAYS ascending (oldest first); the ONLY branch on `order` is the
|
||||
// visual reversal below. The composer anchor is attached to the newest message;
|
||||
// any expanded message can additionally start a reply of its own.
|
||||
export interface ThreadViewProps {
|
||||
subject: string;
|
||||
messages: MessageEnvelope[];
|
||||
order: "asc" | "desc";
|
||||
replyKindSignal?: { kind: string; seq: number };
|
||||
}
|
||||
|
||||
function formatAddress(a: { name: string; email: string } | undefined): string {
|
||||
if (!a || !a.email) return "(unknown)";
|
||||
return a.name ? `${a.name} (${a.email})` : a.email;
|
||||
}
|
||||
|
||||
function formatList(addrs: { name: string; email: string }[]): string {
|
||||
return addrs.length > 0 ? addrs.map(formatAddress).join(", ") : "(no recipient)";
|
||||
}
|
||||
|
||||
// Attribution line prefixed to a quoted original, e.g.
|
||||
// "On Fri, Jul 17, 2026, at 2:04 PM, Lukas Remis wrote:".
|
||||
function attributionFor(msg: MessageEnvelope): string {
|
||||
const d = new Date(msg.date);
|
||||
const date = d.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
const time = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
|
||||
const who = msg.from_addr[0]?.name || msg.from_addr[0]?.email || "";
|
||||
return `On ${date}, at ${time}, ${who} wrote:`;
|
||||
}
|
||||
|
||||
function buildReplyInit(subject: string, msg: MessageEnvelope, kind: string): ComposerInit {
|
||||
const from = msg.from_addr[0]?.email ?? "";
|
||||
const toEmails = msg.to_addr.map((a) => a.email);
|
||||
const norm = subject.replace(/^\s*(re|fwd?):\s*/i, "");
|
||||
const quote = { attribution: attributionFor(msg), messageId: msg.id };
|
||||
if (kind === "forward") {
|
||||
return {
|
||||
accountId: msg.account_id,
|
||||
threadId: msg.thread_id,
|
||||
replyToMessageId: msg.id,
|
||||
kind,
|
||||
subject: `Fwd: ${norm}`,
|
||||
quote,
|
||||
};
|
||||
}
|
||||
const to = kind === "reply_all" ? [from, ...toEmails].filter(Boolean).join(", ") : from;
|
||||
const cc = kind === "reply_all" ? msg.cc_addr.map((a) => a.email).join(", ") : "";
|
||||
return {
|
||||
accountId: msg.account_id,
|
||||
threadId: msg.thread_id,
|
||||
replyToMessageId: msg.id,
|
||||
kind,
|
||||
to,
|
||||
cc,
|
||||
subject: `Re: ${norm}`,
|
||||
quote,
|
||||
};
|
||||
}
|
||||
|
||||
function initials(msg: MessageEnvelope): string {
|
||||
const name = msg.from_addr[0]?.name || msg.from_addr[0]?.email || "?";
|
||||
return name.trim().slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
function MessageItem({
|
||||
message,
|
||||
expanded,
|
||||
showReply,
|
||||
onToggle,
|
||||
onReply,
|
||||
innerRef,
|
||||
}: {
|
||||
message: MessageEnvelope;
|
||||
expanded: boolean;
|
||||
showReply: boolean;
|
||||
onToggle: () => void;
|
||||
onReply: (kind: string) => void;
|
||||
innerRef?: (el: HTMLDivElement | null) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const replyBtn = (kind: string, label: string, icon: string) => (
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-md border border-app-border px-2.5 py-1 text-xs text-app-fg hover:bg-app-hover"
|
||||
onClick={() => onReply(kind)}
|
||||
>
|
||||
<Icon name={icon} size={14} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-testid="message"
|
||||
data-message-id={message.id}
|
||||
ref={innerRef}
|
||||
className="overflow-hidden rounded-lg border border-app-border bg-app-surface"
|
||||
>
|
||||
<button className="flex w-full items-start gap-3 px-3 py-2.5 text-left hover:bg-app-hover" onClick={onToggle}>
|
||||
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-app-active text-sm font-medium text-app-fg">
|
||||
{initials(message)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-baseline gap-2">
|
||||
{message.is_unread && <span className="h-2 w-2 shrink-0 rounded-full bg-app-accent" />}
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
<span className="font-medium text-app-fg">{formatAddress(message.from_addr[0])}</span>
|
||||
<span className="text-app-faint"> → {formatList(message.to_addr)}</span>
|
||||
{message.cc_addr.length > 0 && (
|
||||
<span className="text-app-faint"> · cc: {formatList(message.cc_addr)}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-app-faint">{new Date(message.date).toLocaleString()}</span>
|
||||
</span>
|
||||
<span className="block truncate text-[13px] text-app-muted">{message.snippet}</span>
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="border-t border-app-border">
|
||||
<MessageBody message={message} />
|
||||
{showReply && (
|
||||
<div className="flex justify-end gap-2 border-t border-app-border px-3 py-2">
|
||||
{replyBtn("reply", t("mail.reply"), "reply")}
|
||||
{replyBtn("reply_all", t("mail.replyAll"), "reply-all")}
|
||||
{replyBtn("forward", t("mail.forward"), "forward")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThreadView({ subject, messages, order, replyKindSignal }: ThreadViewProps) {
|
||||
const t = useT();
|
||||
const newest = messages.length > 0 ? messages[messages.length - 1] : undefined;
|
||||
|
||||
const defaultExpanded = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const m of messages) if (m.is_unread) ids.add(m.id);
|
||||
if (newest) ids.add(newest.id);
|
||||
return ids;
|
||||
}, [messages, newest]);
|
||||
|
||||
const [expanded, setExpanded] = useState<Set<string>>(defaultExpanded);
|
||||
useEffect(() => setExpanded(defaultExpanded), [defaultExpanded]);
|
||||
|
||||
// The reply currently open: which message it targets, and its prefilled draft.
|
||||
const [composing, setComposing] = useState<{ anchorId: string; init: ComposerInit } | null>(null);
|
||||
|
||||
const openReply = (msg: MessageEnvelope, kind: string) => {
|
||||
setExpanded((prev) => new Set(prev).add(msg.id));
|
||||
setComposing({ anchorId: msg.id, init: buildReplyInit(subject, msg, kind) });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (replyKindSignal && newest) openReply(newest, replyKindSignal.kind);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [replyKindSignal?.seq]);
|
||||
|
||||
const newestRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
newestRef.current?.scrollIntoView?.({ block: "nearest" });
|
||||
}, [newest?.id]);
|
||||
|
||||
const visual = order === "asc" ? messages : [...messages].reverse();
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const composerFor = (id: string) =>
|
||||
composing?.anchorId === id ? (
|
||||
<div className="py-2">
|
||||
<Composer init={composing.init} onClose={() => setComposing(null)} />
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div data-testid="thread-view" className="flex h-full flex-col overflow-y-auto bg-app-bg">
|
||||
<div className="sticky top-0 z-10 flex h-[44px] items-center justify-between border-b border-app-border bg-app-surface px-4">
|
||||
<h2 className="truncate text-base font-semibold text-app-fg">{subject || "(no subject)"}</h2>
|
||||
<button
|
||||
className="shrink-0 text-xs text-app-accent hover:underline"
|
||||
onClick={() => setExpanded(new Set(messages.map((m) => m.id)))}
|
||||
>
|
||||
{t("mail.expandAll")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 p-3">
|
||||
{visual.map((message) => {
|
||||
const isNewest = Boolean(newest && message.id === newest.id);
|
||||
const isComposing = composing?.anchorId === message.id;
|
||||
return (
|
||||
<Fragment key={message.id}>
|
||||
<MessageItem
|
||||
message={message}
|
||||
expanded={expanded.has(message.id)}
|
||||
showReply={expanded.has(message.id) && !isComposing}
|
||||
onToggle={() => toggle(message.id)}
|
||||
onReply={(kind) => openReply(message, kind)}
|
||||
innerRef={isNewest ? (el) => (newestRef.current = el) : undefined}
|
||||
/>
|
||||
{isNewest ? (
|
||||
<div data-testid="composer-anchor">{composerFor(message.id)}</div>
|
||||
) : (
|
||||
composerFor(message.id)
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user