import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { languageName, needsTranslation, sameLanguage, translate, useLocale, useT } from "../../i18n"; import type { MessageKey } from "../../i18n/en"; import { personHue } from "../../lib/avatarColor"; import { setDarkMessages, useDarkMessages } from "../../lib/darkMessage"; import { api } from "../../lib/api"; import { printMessages } from "../../lib/print"; import { replyTargets } from "../../lib/replyTargets"; import { useTheme } from "../../lib/theme"; 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 { useSubscriptionActions } from "../list/SubscriptionsList"; 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(getDateLocale(), { hour: "numeric", minute: "2-digit", hour12: getHour12(), }); const who = msg.from_addr[0]?.name || msg.from_addr[0]?.email || ""; return translate("compose.attribution", { date, time, who }); } /** The header block above a forwarded message, as mail programs write it: * who sent it (name and address), when, its subject and recipients. */ function forwardHeaderFor(msg: MessageEnvelope, subject: string): string { const d = new Date(msg.date); const date = d.toLocaleString(getDateLocale(), { weekday: "short", year: "numeric", month: "short", day: "numeric", hour: "numeric", minute: "2-digit", hour12: getHour12(), }); const person = (a: { name: string; email: string }) => a.name && a.name !== a.email ? `${a.name} <${a.email}>` : a.email; const lines = [ "---------- Forwarded message ---------", `From: ${msg.from_addr.map(person).join(", ")}`, `Date: ${date}`, `Subject: ${msg.subject || subject}`, ]; if (msg.to_addr.length) lines.push(`To: ${msg.to_addr.map(person).join(", ")}`); if (msg.cc_addr.length) lines.push(`Cc: ${msg.cc_addr.map(person).join(", ")}`); return lines.join("\n"); } function buildReplyInit( subject: string, msg: MessageEnvelope, kind: string, own: Set, ): ComposerInit { 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: { attribution: forwardHeaderFor(msg, subject), messageId: msg.id }, forwardFrom: msg.id, }; } const targets = replyTargets(msg, own, kind); const to = targets.to.join(", "); const cc = targets.cc.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 { return personHue(msg.from_addr[0]?.email || msg.from_addr[0]?.name || "?"); } function fmtRelative(iso: string): string { const d = new Date(iso); const base = d.toLocaleDateString(getDateLocale(), { 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 = translate("time.justNow"); else if (mins < 60) rel = translate("time.minAgo", { count: mins }); else if (hrs < 24) rel = translate("time.hoursAgo", { count: hrs }); else if (days < 30) rel = translate("time.daysAgo", { count: days }); 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(), }); } // "Unsubscribe" beside the sender of bulk mail (its List-Unsubscribe header), // as Gmail has it; "Unsubscribed" once done. function UnsubscribeLink({ message, name }: { message: MessageEnvelope; name: string }) { const t = useT(); const { unsubscribe, dialog } = useSubscriptionActions(); if (message.unsubscribed) { return {t("subs.unsubscribed")}; } return ( <> {dialog} ); } 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`; } // A message's folder, named as in the sidebar (in the interface language). const LOCATION_LABEL: Record = { inbox: "nav.inbox", sent: "nav.sent", archive: "nav.archive", drafts: "nav.drafts", trash: "nav.trash", junk: "nav.spam", }; function ActionsMenu({ messageId, mailboxId, onReply, onPrint, onDelete, replyDisabled = false, allLabels, messageLabels, onToggleLabel, onCreateLabel, translateLanguages, translateTo, onTranslateTo, }: { messageId: string; /** The message's folder: a move gives plugin items a fresh start. */ mailboxId: string; onReply: (kind: string) => void; onPrint: () => void; onDelete: () => void; replyDisabled?: boolean; allLabels: string[]; messageLabels: string[]; onToggleLabel: (label: string) => void; onCreateLabel: (name: string) => void; /** Languages a message can be translated into; empty: translation is off. */ translateLanguages: string[]; translateTo: string | null; onTranslateTo: (code: string | null) => void; }) { const t = useT(); const { locale } = useLocale(); const [open, setOpen] = useState(false); const [labelsOpen, setLabelsOpen] = useState(false); const [translateOpen, setTranslateOpen] = 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); setTranslateOpen(false); setCreating(false); setNewName(""); } }, [open]); const item = (label: string, onClick: () => void, disabled = false) => ( ); return (
{/* Always rendered, hidden while closed: plugin items (below) load with the message, not when the menu opens — on a slow connection their fetch would otherwise hold them back for seconds. */}