"use client"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { motion } from "framer-motion"; import { ArrowLeft, ChevronDown, CornerUpLeft, Download, FolderInput, Forward, Image as ImageIcon, MailOpen, Reply, ReplyAll, ShieldCheck, Trash2, X, } from "lucide-react"; import Link from "next/link"; import { useI18n } from "../i18n/provider"; import { Avatar } from "./Avatar"; import { Composer, type ComposerInitial } from "./Composer"; import { MessageFrame } from "./MessageFrame"; import { initiallyOpen, messageKey, replyTarget, type ThreadMessageInfo, type ThreadSummary, } from "../lib/conversations"; import { hasBlockedRemoteContent, messageDocument } from "../lib/renderMessage"; import { useTheme } from "../lib/theme"; import { offersReplyAll, replyAllRecipients, replyRecipients } from "../lib/recipients"; import { plainSnippet } from "../lib/snippet"; import { notifyMailStateChanged } from "../lib/useFolders"; import type { InlineImages } from "../lib/emailBody"; /** One message as the mail API returns it with its body. */ interface FullMessage { uid: number; subject: string; from: string; to: string[]; cc: string[]; date: string; text: string; html: string; attachments: string[]; message_id?: string; important?: boolean; inline_images: InlineImages; } interface MoveTarget { name: string; label: string; } interface Trusted { isTrusted: (from: string) => boolean; trust: (from: string) => Promise; revoke: (from: string) => Promise; } interface Props { thread: ThreadSummary; folder: string; ownAddress: string; trusted: Trusted; moveTargets: MoveTarget[]; deleteIsPermanent: boolean; onClose: () => void; /** Something changed that the list and the badges should show. */ onChanged: () => void; } const commandClass = "inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[13px] text-slate-200 transition-colors hover:bg-ink/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 md:px-3"; const infoLinkClass = "rounded-md px-1.5 py-1 text-[12.5px] font-medium text-indigo-300 transition-colors hover:bg-ink/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"; const replyButtonClass = "inline-flex h-10 items-center gap-2 rounded-xl px-4 text-[13.5px] text-slate-200 shadow-[inset_0_0_0_1px_rgb(var(--ink)/0.18)] transition-colors hover:bg-ink/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"; /** The name and address in a header like `Jenny `. */ function splitSender(from: string): { name: string; address: string } { const match = from.match(/^\s*"?([^"<]*?)"?\s*<([^>]+)>\s*$/); if (match) return { name: match[1].trim(), address: match[2].trim() }; return { name: "", address: from.trim() }; } function initialsOf(name: string, address: string): string { const source = (name || address || "?").trim(); const parts = source.replace(/[<>"]/g, "").split(/[\s.@_-]+/).filter(Boolean); return (parts.slice(0, 2).map((p) => p[0]).join("") || "?").toUpperCase(); } export function ThreadReader(props: Props) { const { thread, folder, ownAddress, trusted } = props; const { t, locale } = useI18n(); const { theme } = useTheme(); // Plain messages that are shown as sent instead of in the app's colours. const [originals, setOriginals] = useState>(new Set()); const [infos, setInfos] = useState(null); const [error, setError] = useState(null); const [open, setOpen] = useState>(new Set()); const [bodies, setBodies] = useState>({}); // Images shown for this message only, by the reader's choice; a trusted // sender's are shown without one. const [imagesOnce, setImagesOnce] = useState>(new Set()); const [reply, setReply] = useState<{ nonce: number; initial: ComposerInitial } | null>(null); const [moveOpen, setMoveOpen] = useState(false); const [busy, setBusy] = useState(false); const fetching = useRef>(new Set()); const replyAnchor = useRef(null); const loadedFor = useRef(""); const load = useCallback( async (initial: boolean) => { try { const res = await fetch( `/api/v1/mail/thread/${encodeURIComponent(thread.thread_id)}?folder=${encodeURIComponent(folder)}`, { cache: "no-store" }, ); if (res.status === 401) { window.location.href = "/user/login"; return; } if (!res.ok) throw new Error(t("errors.serverError")); const data: ThreadMessageInfo[] = await res.json(); setInfos(data); setError(null); setOpen((current) => { if (initial) return initiallyOpen(data); // After sending, the new message is the one to see. const next = new Set(current); if (data.length > 0) next.add(messageKey(data[data.length - 1])); return next; }); } catch (err) { setError(err instanceof Error ? err.message : t("errors.serverError")); } }, [thread.thread_id, folder, t], ); useEffect(() => { if (loadedFor.current === thread.thread_id) return; loadedFor.current = thread.thread_id; setInfos(null); setBodies({}); setImagesOnce(new Set()); setReply(null); fetching.current = new Set(); void load(true); }, [thread.thread_id, load]); /** Fetches one message's body. Opening it is also what marks it read. */ const fetchBody = useCallback( async (info: ThreadMessageInfo): Promise => { const key = messageKey(info); if (fetching.current.has(key)) return null; fetching.current.add(key); try { const res = await fetch( `/api/v1/mail/message/${info.uid}?folder=${encodeURIComponent(info.folder)}`, { cache: "no-store" }, ); if (!res.ok) throw new Error("fetch"); const full: FullMessage = await res.json(); setBodies((current) => ({ ...current, [key]: full })); if (!info.seen) { setInfos((current) => current ? current.map((m) => (messageKey(m) === key ? { ...m, seen: true } : m)) : current, ); notifyMailStateChanged(); props.onChanged(); } return full; } catch { setBodies((current) => ({ ...current, [key]: "error" })); return null; } finally { fetching.current.delete(key); } }, // onChanged is a fresh closure each render; the fetch must not depend on it. // eslint-disable-next-line react-hooks/exhaustive-deps [], ); useEffect(() => { if (!infos) return; for (const info of infos) { const key = messageKey(info); if (open.has(key) && !bodies[key]) void fetchBody(info); } }, [infos, open, bodies, fetchBody]); const toggle = (info: ThreadMessageInfo) => { const key = messageKey(info); setOpen((current) => { const next = new Set(current); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }; const inThisFolder = useMemo( () => (infos ?? []).filter((m) => m.folder.toLowerCase() === folder.toLowerCase()), [infos, folder], ); /** Runs one request per message of the conversation that sits in this folder. */ const forEachHere = async (path: string, extra: (m: ThreadMessageInfo) => Record) => { setBusy(true); try { await Promise.all( inThisFolder.map((m) => fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ folder, uid: m.uid, ...extra(m) }), }), ), ); } finally { setBusy(false); notifyMailStateChanged(); props.onChanged(); } }; const deleteThread = async () => { await forEachHere("/api/v1/mail/delete", () => ({})); props.onClose(); }; const moveThread = async (target: string) => { setMoveOpen(false); await forEachHere("/api/v1/mail/move", () => ({ target })); props.onClose(); }; const markUnread = async () => { const newest = inThisFolder[inThisFolder.length - 1]; if (!newest) return; setBusy(true); try { await fetch("/api/v1/mail/seen", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ folder, uid: newest.uid, seen: false }), }); } finally { setBusy(false); notifyMailStateChanged(); props.onChanged(); } props.onClose(); }; /** Opens the reply under the conversation, addressed from the message it answers. */ const startReply = async (mode: "reply" | "replyAll") => { if (!infos) return; const target = replyTarget(infos); if (!target) return; const key = messageKey(target); const full = (bodies[key] && bodies[key] !== "error" ? (bodies[key] as FullMessage) : null) ?? (await fetchBody(target)); if (!full) return; const recipients = mode === "replyAll" ? replyAllRecipients(full, ownAddress) : replyRecipients(full, ownAddress); setReply({ nonce: Date.now(), initial: { mode, subject: full.subject, replyTo: recipients.to.join(", "), cc: recipients.cc.join(", "), inReplyTo: full.message_id ?? "", }, }); setTimeout(() => replyAnchor.current?.scrollIntoView({ behavior: "smooth", block: "end" }), 50); }; const lastIncoming = infos ? replyTarget(infos) : null; const canReplyAll = (() => { if (!lastIncoming) return false; const full = bodies[messageKey(lastIncoming)]; return full && full !== "error" ? offersReplyAll(full, ownAddress) : false; })(); const waitingOnMe = infos && infos.length > 0 && !infos[infos.length - 1].own; const iReplied = lastIncoming ? lastIncoming.answered : false; const dateText = (iso: string, long: boolean) => { const d = new Date(iso); if (Number.isNaN(d.getTime())) return ""; return long ? d.toLocaleString(locale, { weekday: "short", day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : d.toLocaleDateString(locale, { day: "numeric", month: "short" }); }; return ( {/* The commands that apply to the whole conversation. */}
{canReplyAll && ( )} {props.moveTargets.length > 0 && (
{moveOpen && (
{props.moveTargets.map((target) => ( ))}
)}
)}

{thread.subject || t("mail.noSubject")}

{(infos?.length ?? thread.message_count) > 1 && ( {t("conversations.messagesInConversation", { count: infos?.length ?? thread.message_count })} )} {infos && waitingOnMe && lastIncoming && ( {iReplied ? t("conversations.youReplied") : t("conversations.notReplied")} )}
{error && (
{error}
)} {!infos && !error &&
{t("common.loading")}
} {infos && infos.length === 0 && (
{t("conversations.conversationEmpty")}
)} {infos?.map((info) => { const key = messageKey(info); const isOpen = open.has(key); const full = bodies[key]; const loaded = full && full !== "error" ? full : null; const sender = loaded ? splitSender(loaded.from) : { name: info.from_display_name, address: info.sender_address }; const displayName = info.own ? t("conversations.me") : sender.name || info.from_display_name || sender.address; const trustedHere = loaded ? trusted.isTrusted(loaded.from) : false; const showImages = trustedHere || imagesOnce.has(key); const rendered = loaded ? messageDocument(loaded, showImages, { theme, original: originals.has(key) }) : null; const blocked = loaded ? hasBlockedRemoteContent(loaded) : false; return (
{isOpen && (
{full === "error" && (
{t("errors.serverError")}
)} {!loaded && full !== "error" && (
{t("conversations.loadingMessage")}
)} {loaded && ( <>
{loaded.to.length > 0 && ( {t("ui.toRecipients")}: {loaded.to.slice(0, 3).join(", ")} {loaded.to.length > 3 && ` +${loaded.to.length - 3}`} )} {!info.own && ( )} {t("mail.forward")}
{blocked && !trustedHere && (
{imagesOnce.has(key) ? t("mail.imagesEnabled") : t("mail.remoteContentBlocked")}
)} {loaded.attachments.length > 0 && (
{loaded.attachments.map((filename, idx) => ( {filename} ))}
)} {rendered?.canAdapt && (
)} {rendered ? ( ) : (
                            {loaded.text || t("mail.emptyBody")}
                          
)} )}
)}
); })} {/* The reply is written here, under the messages it answers, so they stay in view while it is typed. */}
{reply ? (
{ setReply(null); void load(false); notifyMailStateChanged(); props.onChanged(); }} onDiscarded={() => setReply(null)} />
) : ( infos && infos.length > 0 && (
{canReplyAll && ( )}
) )}
); }