import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { useT } from "../../i18n"; import { api } from "../../lib/api"; import { printMessages } from "../../lib/print"; import { useIsMobile } from "../../lib/useIsMobile"; import { getDateLocale, getHour12 } from "../../lib/timeFormat"; import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types"; import { PluginSlot } from "../../plugins/PluginSlot"; import { Composer, type ComposerInit } from "../compose/Composer"; import { Icon } from "../Icon"; import { AvatarImage } from "../SenderAvatar"; import { clearPdfPasswordCache } from "./AttachmentPreview"; import { LabelChips } from "../mail/LabelChips"; 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. export interface ThreadViewProps { subject: string; messages: MessageEnvelope[]; order: "asc" | "desc"; mailboxes?: MailboxOut[]; accounts?: AccountOut[]; 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)"; } function recipientSummary(addrs: { name: string; email: string }[]): string { if (addrs.length === 0) return "(no recipient)"; const first = addrs[0].name || addrs[0].email; return addrs.length > 1 ? `${first} +${addrs.length - 1}` : first; } function attributionFor(msg: MessageEnvelope): string { const d = new Date(msg.date); const date = d.toLocaleDateString(getDateLocale(), { weekday: "short", month: "short", day: "numeric", year: "numeric", }); const time = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: getHour12(), }); 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 || "?").trim(); const words = name.split(/\s+/).filter(Boolean); if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase(); return name.slice(0, 1).toUpperCase(); } // Stable per-sender hue so a sender's avatar + name share a colour (like the mockup). function hue(msg: MessageEnvelope): number { const key = (msg.from_addr[0]?.email || msg.from_addr[0]?.name || "?").toLowerCase(); let h = 0; for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) % 360; return h; } function fmtRelative(iso: string): string { const d = new Date(iso); const base = d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); const diff = Date.now() - d.getTime(); const mins = Math.round(diff / 60000); const hrs = Math.round(diff / 3600000); const days = Math.round(diff / 86400000); let rel = ""; if (mins < 1) rel = "just now"; else if (mins < 60) rel = `${mins} min ago`; else if (hrs < 24) rel = `${hrs} hour${hrs === 1 ? "" : "s"} ago`; else if (days < 30) rel = `${days} day${days === 1 ? "" : "s"} ago`; return rel ? `${base} (${rel})` : `${base} ${d.getFullYear()}`; } function fmtFull(iso: string): string { return new Date(iso).toLocaleString(getDateLocale(), { weekday: "short", year: "numeric", month: "short", day: "numeric", hour: "numeric", minute: "2-digit", hour12: getHour12(), }); } function fmtSize(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`; } const LOCATION_LABEL: Record = { inbox: "Inbox", sent: "Sent", archive: "Archive", drafts: "Drafts", trash: "Trash", junk: "Spam", }; function ActionsMenu({ messageId, onReply, onPrint, onDelete, replyDisabled = false, allLabels, messageLabels, onToggleLabel, onCreateLabel, }: { messageId: string; onReply: (kind: string) => void; onPrint: () => void; onDelete: () => void; replyDisabled?: boolean; allLabels: string[]; messageLabels: string[]; onToggleLabel: (label: string) => void; onCreateLabel: (name: string) => void; }) { const t = useT(); const [open, setOpen] = useState(false); const [labelsOpen, setLabelsOpen] = useState(false); const [creating, setCreating] = useState(false); const [newName, setNewName] = useState(""); const ref = useRef(null); useEffect(() => { if (!open) return; const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; // Clicking inside the sandboxed message-body iframe doesn't reach document // mousedown, but it blurs the parent window — close the menu on that too. const onBlur = () => setOpen(false); document.addEventListener("mousedown", onDoc); window.addEventListener("blur", onBlur); return () => { document.removeEventListener("mousedown", onDoc); window.removeEventListener("blur", onBlur); }; }, [open]); useEffect(() => { if (!open) { setLabelsOpen(false); setCreating(false); setNewName(""); } }, [open]); const item = (label: string, onClick: () => void, disabled = false) => ( ); return (
{open && (
{item(t("mail.reply"), () => onReply("reply"), replyDisabled)} {item(t("mail.replyAll"), () => onReply("reply_all"), replyDisabled)} {item(t("mail.forward"), () => onReply("forward"), replyDisabled)} {item(t("mail.forwardAsAttachment"), () => onReply("forward"), replyDisabled)} {item(t("mail.print"), onPrint)}
{/* Labels submenu: multi-select checkboxes (menu stays open) + create new. */} {labelsOpen && (
{allLabels.map((l) => ( ))} {allLabels.length === 0 && (
{t("mail.noLabels")}
)} {creating ? ( setNewName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && newName.trim()) { onCreateLabel(newName.trim()); setNewName(""); setCreating(false); } else if (e.key === "Escape") { setCreating(false); setNewName(""); } }} placeholder={t("mail.labelName")} className="m-1 w-[calc(100%-0.5rem)] rounded border border-app-border bg-app-surface px-2 py-1 text-xs outline-none focus:border-app-accent" /> ) : ( )}
)} {/* Plugin verbs that belong in the menu rather than on the header (SPEC §11.3 message.actions.menu). Clicking anything inside closes the menu, so a bundle only has to perform its action. */}
setOpen(false)}>
{item(t("mail.delete"), onDelete)}
)}
); } function MessageItem({ message, expanded, location, align, accentColor, boxClassName, zIndex, showReplyBar, canReply, alsoIn, onToggle, onReply, onMailto, onToggleRead, onPrint, onDelete, innerRef, allLabels, messageLabels, onToggleLabel, onCreateLabel, }: { message: MessageEnvelope; expanded: boolean; location: string; align: "start" | "end"; accentColor: string | null; boxClassName: string; zIndex: number; showReplyBar: boolean; canReply: boolean; alsoIn: string[]; onToggle: () => void; onReply: (kind: string) => void; onMailto: (email: string) => void; onToggleRead: () => void; onPrint: () => void; onDelete: () => void; innerRef?: (el: HTMLDivElement | null) => void; allLabels: string[]; messageLabels: string[]; onToggleLabel: (label: string) => void; onCreateLabel: (name: string) => void; }) { const t = useT(); const [showDetails, setShowDetails] = useState(false); const replyBtn = (kind: string, label: string, icon: string) => ( ); const h = hue(message); // Our own IMAP accounts use their assigned colour; everyone else a stable hue. const avatarBg = accentColor ?? `hsl(${h} 45% 45%)`; const nameColor = accentColor ?? `hsl(${h} 55% 62%)`; const avatar = () => ( {initials(message)} ); const senderName = ( {message.from_addr[0]?.name || message.from_addr[0]?.email || "(unknown)"} ); const multiRecipient = message.to_addr.length + message.cc_addr.length > 1; return (
{!expanded ? ( // Collapsed: single-line row (avatar · sender · inline snippet · date). // Aligned to the visible (non-overlapped) edge of the stacked card. ) : (
{avatar()}
{/* line 1: sender + email ············· date (whole row collapses) */} {/* line 2: to … ▼ ············· location ○ reply actions */}
{location && {location}} {canReply && ( )} {/* Plugin-contributed per-message actions (SPEC §11.3 message.actions). */}
{alsoIn.length > 0 && (
{t("mail.alsoIn")} {alsoIn.join(", ")}
)} {showDetails && (
{t("mail.from")}
{formatList(message.from_addr)}
{t("mail.to")}
{formatList(message.to_addr)}
{message.cc_addr.length > 0 && ( <>
Cc
{formatList(message.cc_addr)}
)}
{t("mail.subjectLabel")}
{message.subject || "(no subject)"}
{t("mail.dateLabel")}
{fmtFull(message.date)}
{t("mail.sizeLabel")}
{fmtSize(message.size_bytes)}
)}
{/* Plugin-contributed per-message banner (SPEC §11.3 message.banner). */}
{showReplyBar && canReply && (
{replyBtn("reply", t("mail.reply"), "reply")} {replyBtn("reply_all", t("mail.replyAll"), "reply-all")} {replyBtn("forward", t("mail.forward"), "forward")}
)}
)}
); } export function ThreadView({ subject, messages, order, mailboxes, accounts, replyKindSignal, }: ThreadViewProps) { const t = useT(); const queryClient = useQueryClient(); const newest = messages.length > 0 ? messages[messages.length - 1] : undefined; const defaultExpanded = useMemo(() => { const ids = new Set(); 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>(defaultExpanded); useEffect(() => setExpanded(defaultExpanded), [defaultExpanded]); const [composing, setComposing] = useState<{ anchorId: string; init: ComposerInit } | null>(null); const flags = useMutation({ mutationFn: ({ id, add, remove }: { id: string; add: string[]; remove: string[] }) => api.messageFlags(id, add, remove), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["thread"] }); queryClient.invalidateQueries({ queryKey: ["threads"] }); // Mark read/unread changes the Inbox unread badge. queryClient.invalidateQueries({ queryKey: ["folder-counts"] }); }, }); // --- labels: per-message storage + a thread union for display (chips + list) --- const messageIds = useMemo(() => messages.map((m) => m.id), [messages]); const threadId = messages[0]?.thread_id ?? null; // Forget remembered protected-PDF passwords when the open thread changes or closes. useEffect(() => () => clearPdfPasswordCache(), [threadId]); const allLabelsQuery = useQuery({ queryKey: ["labels-all"], queryFn: api.labelsAll }); const msgLabelsQuery = useQuery({ queryKey: ["message-labels", messageIds], queryFn: () => api.messageLabelsBatch(messageIds), enabled: messageIds.length > 0, }); const allLabels = allLabelsQuery.data ?? []; const msgLabels = msgLabelsQuery.data ?? {}; const threadLabels = useMemo( () => [...new Set(Object.values(msgLabels).flat())].sort((a, b) => a.localeCompare(b)), // eslint-disable-next-line react-hooks/exhaustive-deps [msgLabelsQuery.data], ); // Recompute + persist the thread's label union (list chips read this), then tell // the sidebar + other queries a label change happened. const persistUnionAndNotify = async () => { const fresh = await api.messageLabelsBatch(messageIds); const union = [...new Set(Object.values(fresh).flat())].sort((a, b) => a.localeCompare(b)); if (threadId) await api.setThreadLabels(threadId, union); }; const onLabelsChanged = () => { queryClient.invalidateQueries({ queryKey: ["message-labels"] }); queryClient.invalidateQueries({ queryKey: ["thread-labels"] }); queryClient.invalidateQueries({ queryKey: ["labels-all"] }); queryClient.invalidateQueries({ queryKey: ["threads"] }); window.dispatchEvent(new Event("webmail:labels-changed")); }; const applyLabels = useMutation({ mutationFn: async ({ messageId, labels }: { messageId: string; labels: string[] }) => { await api.setMessageLabels(messageId, labels); await persistUnionAndNotify(); }, onSuccess: onLabelsChanged, }); const removeThreadLabel = useMutation({ mutationFn: async (label: string) => { for (const m of messages) { const cur = msgLabels[m.id] ?? []; if (cur.includes(label)) await api.setMessageLabels(m.id, cur.filter((x) => x !== label)); } await persistUnionAndNotify(); }, onSuccess: onLabelsChanged, }); const toggleLabel = (messageId: string, label: string) => { const cur = msgLabels[messageId] ?? []; const next = cur.includes(label) ? cur.filter((x) => x !== label) : [...cur, label]; applyLabels.mutate({ messageId, labels: next }); }; const createLabel = async (name: string, messageId: string) => { await api.labelsCreate(name); toggleLabel(messageId, name); }; const move = useMutation({ mutationFn: ({ id, mailboxId }: { id: string; mailboxId: string }) => api.moveMessage(id, mailboxId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["thread"] }); queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["folder-counts"] }); }, }); const specialUse = useMemo(() => { const m = new Map(); for (const mb of mailboxes ?? []) m.set(mb.id, mb.special_use); return m; }, [mailboxes]); const locationFor = (msg: MessageEnvelope) => { const su = specialUse.get(msg.mailbox_id); return su ? (LOCATION_LABEL[su] ?? su.charAt(0).toUpperCase() + su.slice(1)) : ""; }; const trashFor = (accountId: string) => (mailboxes ?? []).find((m) => m.account_id === accountId && m.special_use === "trash")?.id; const accountColor = useMemo(() => { const m = new Map(); for (const a of accounts ?? []) m.set(a.email.toLowerCase(), a.color); return m; }, [accounts]); const accentFor = (msg: MessageEnvelope) => accountColor.get((msg.from_addr[0]?.email ?? "").toLowerCase()) ?? null; const toggleRead = (msg: MessageEnvelope) => flags.mutate( msg.is_unread ? { id: msg.id, add: ["\\Seen"], remove: [] } : { id: msg.id, add: [], remove: ["\\Seen"] }, ); const deleteMessage = (msg: MessageEnvelope) => { const trash = trashFor(msg.account_id); if (trash) move.mutate({ id: msg.id, mailboxId: trash }); }; const openReply = (msg: MessageEnvelope, kind: string) => { if (specialUse.get(msg.mailbox_id) === "trash") return; // no reply/forward from Trash setExpanded((prev) => new Set(prev).add(msg.id)); setComposing({ anchorId: msg.id, init: buildReplyInit(subject, msg, kind) }); }; // A mailto: link in the message body opens a fresh composer to that address, // sent from the account this message belongs to. const openMailto = (msg: MessageEnvelope, email: string) => { setExpanded((prev) => new Set(prev).add(msg.id)); setComposing({ anchorId: msg.id, init: { accountId: msg.account_id, kind: "new", to: email, subject: "" }, }); }; useEffect(() => { if (replyKindSignal && newest) openReply(newest, replyKindSignal.kind); // eslint-disable-next-line react-hooks/exhaustive-deps }, [replyKindSignal?.seq]); const newestRef = useRef(null); useEffect(() => { newestRef.current?.scrollIntoView?.({ block: "nearest" }); }, [newest?.id]); const visual = order === "asc" ? messages : [...messages].reverse(); const allExpanded = messages.length > 0 && messages.every((m) => expanded.has(m.id)); const toggle = (id: string) => setExpanded((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); // Default action expands everything; collapsing leaves only the latest message open. const toggleAll = () => setExpanded( allExpanded ? new Set(newest ? [newest.id] : []) : new Set(messages.map((m) => m.id)), ); const composerRef = useRef(null); // On a phone, reply/reply-all/forward open full screen rather than inline. const mobile = useIsMobile(); // Bring the inline composer into view when it opens (reply/reply-all/forward). useEffect(() => { if (composing && !mobile) { composerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); } }, [composing, mobile]); const composerFor = (id: string) => composing?.anchorId === id ? ( mobile ? (
setComposing(null)} fullWindow />
) : (
setComposing(null)} />
) ) : null; return (

{subject || "(no subject)"}

removeThreadLabel.mutate(l)} />
{visual.map((message, i) => { const isNewest = Boolean(newest && newest.id === message.id); const isExpanded = expanded.has(message.id); const prev = i > 0 ? visual[i - 1] : undefined; const prevCollapsed = prev ? !expanded.has(prev.id) : false; // Cards overlap by a negative top margin (tuck up under the card above), // with the newer card kept on top by z-index. The card above is OLDER in // oldest-first but NEWER in newest-first, so the rule differs by order: // - oldest-first: an expanded card tucks up over the older card above it // (its own top stays clear). Only overlap a collapsed card above. // - newest-first: the card above is newer, so tucking an expanded card up // would hide its header under that newer card. Only collapsed cards tuck // up; an expanded card keeps its gap, so its top stays clear of the newer // message and the older card BELOW tucks under it instead. const overlap = order === "desc" ? !isExpanded : prevCollapsed; const margin = i === 0 ? "" : overlap ? "-mt-[20px]" : "mt-2"; const z = order === "asc" ? i + 1 : visual.length - i; const boxClassName = `${margin} rounded-lg border border-app-border bg-app-surface ${ isExpanded ? "" : "shadow-sm" }`; // Reply/Reply-all/Forward make no sense on a trashed message. const canReply = specialUse.get(message.mailbox_id) !== "trash"; const alsoIn = (message.also_in_account_ids ?? []) .map((id) => accounts?.find((a) => a.id === id)?.email) .filter((e): e is string => Boolean(e)); return ( toggle(message.id)} onReply={(kind) => openReply(message, kind)} onMailto={(email) => openMailto(message, email)} onToggleRead={() => toggleRead(message)} onPrint={() => void printMessages(subject, [message])} onDelete={() => deleteMessage(message)} innerRef={isNewest ? (el) => (newestRef.current = el) : undefined} allLabels={allLabels} messageLabels={msgLabels[message.id] ?? []} onToggleLabel={(l) => toggleLabel(message.id, l)} onCreateLabel={(name) => void createLabel(name, message.id)} /> {isNewest && (
{composing?.anchorId === message.id ? composerFor(message.id) : null}
)} {composing?.anchorId === message.id && !isNewest && composerFor(message.id)}
); })}
); }