import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AlertOctagon, Archive, ArrowLeft, ChevronDown, ChevronUp, FolderInput, Forward, Mail, MailOpen, MoreVertical, Printer, Reply, ReplyAll, ShieldCheck, Star, Tag, Trash2, Download } from "lucide-react"; import { useMail } from "@/store/mail"; import { useSettings } from "@/store/settings"; import { useCompose } from "@/store/compose"; import type { Email, Id } from "@/jmap/types"; import { MessageView } from "./MessageView"; import type { ListActions } from "./MessageList"; import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; import { Spinner } from "@/ui/misc"; import { client } from "@/jmap/client"; import { LabelPicker } from "./LabelPicker"; import { threadScrollTarget } from "@/lib/threadScroll"; interface Props { threadId: Id; mailboxId: Id | null; onBack: () => void; actions: ListActions; onNavigate: (delta: number) => void; hasPrev: boolean; hasNext: boolean; } export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, hasPrev, hasNext }: Props) { const loadThread = useMail((s) => s.loadThread); const thread = useMail((s) => s.threads[threadId]); const emails = useMail((s) => s.emails); const fullIds = useMail((s) => s.fullIds); const loading = useMail((s) => Boolean(s.loadingThreads[threadId])); const mailboxes = useMail((s) => s.mailboxes); const settings = useSettings((s) => s.settings); const labels = settings.labels; const reply = useCompose((s) => s.reply); const [error, setError] = useState(null); const [expanded, setExpanded] = useState>({}); const [allExpanded, setAllExpanded] = useState(false); const [labelAnchor, setLabelAnchor] = useState<{ x: number; y: number } | null>(null); const moreMenu = useMenu(); const scrollRef = useRef(null); const markTimer = useRef(null); // Load useEffect(() => { setError(null); useMail.getState().setOpenThread(threadId); loadThread(threadId).catch((err) => setError((err as Error).message)); return () => { if (useMail.getState().openThreadId === threadId) useMail.getState().setOpenThread(null); }; }, [threadId, loadThread]); const messages = useMemo(() => { if (!thread) return [] as Email[]; const all = thread.emailIds.map((id) => emails[id]).filter((e): e is Email => Boolean(e && fullIds[e.id])); // Conversation view: hide trash/junk messages unless we're in that folder. const mail = useMail.getState(); const trash = mail.roleId("trash"); const junk = mail.roleId("junk"); const filtered = all.filter((e) => { if (mailboxId && (mailboxId === trash || mailboxId === junk)) return true; if (trash && e.mailboxIds[trash]) return false; if (junk && e.mailboxIds[junk]) return false; return true; }); return (filtered.length ? filtered : all).sort((a, b) => a.receivedAt.localeCompare(b.receivedAt)); }, [thread, emails, fullIds, mailboxId]); /* * Which messages were unread when this conversation was opened. * * Expansion and the unread bar used to read `$seen` directly, so the moment * the auto-mark-read timer fired, every message expanded *because* it was * unread collapsed again -- all but the last -- and the only record of which * ones they were disappeared with them (#69). Opening a thread with several * unread messages gave you a few seconds before the view rearranged itself * underneath you. * * Marking read on the server is still right: opening the thread is the signal * that you are reading it. What was wrong was letting that change the shape * of what you are looking at. The set only ever grows while a thread is open * -- a message that arrives unread joins it -- and is discarded on the way to * another thread. * * Accumulated during render rather than in an effect because it is derived * purely from `messages`, and adding an id twice does nothing. An effect * would repaint a frame later, which is the flicker this exists to remove. */ const threadKey = thread?.id ?? null; const unreadAtOpen = useRef<{ key: Id | null; ids: Set }>({ key: null, ids: new Set() }); if (unreadAtOpen.current.key !== threadKey) unreadAtOpen.current = { key: threadKey, ids: new Set() }; for (const m of messages) if (!m.keywords.$seen) unreadAtOpen.current.ids.add(m.id); const wasUnread = unreadAtOpen.current.ids; // Default expansion: unread when opened + last message expanded, others collapsed const lastId = messages[messages.length - 1]?.id; const isExpanded = useCallback( (e: Email) => { if (e.id in expanded) return expanded[e.id]!; if (allExpanded) return true; return wasUnread.has(e.id) || e.id === lastId || messages.length === 1; }, [expanded, allExpanded, lastId, messages.length, wasUnread], ); // Mark as read after delay useEffect(() => { if (!messages.length) return; const unread = messages.filter((e) => !e.keywords.$seen && isExpanded(e)).map((e) => e.id); if (!unread.length || settings.markReadDelay < 0) return; if (markTimer.current) window.clearTimeout(markTimer.current); markTimer.current = window.setTimeout(() => void useMail.getState().markRead(unread, true), settings.markReadDelay * 1000); return () => { if (markTimer.current) window.clearTimeout(markTimer.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [messages.map((m) => m.id + (m.keywords.$seen ? "1" : "0")).join(","), settings.markReadDelay]); // Open on the first unread message rather than the newest one (#87). useEffect(() => { if (!messages.length || !scrollRef.current) return; const target = threadScrollTarget(messages, wasUnread); if (!target) return; const el = scrollRef.current.querySelector(`[data-msg-id="${CSS.escape(target)}"]`); el?.scrollIntoView({ block: "start" }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [threadId, messages.length > 0]); // Keyboard: reply/forward events from MailView useEffect(() => { const onReply = (ev: Event) => { const mode = (ev as CustomEvent<"reply" | "replyAll" | "forward">).detail; const last = messages[messages.length - 1]; if (last) void reply(last, mode); }; const onNav = (ev: Event) => { const delta = (ev as CustomEvent).detail; const els = Array.from(scrollRef.current?.querySelectorAll("[data-msg-id]") ?? []); if (!els.length) return; const top = scrollRef.current!.getBoundingClientRect().top; let idx = els.findIndex((el) => el.getBoundingClientRect().top - top > 8); if (idx < 0) idx = els.length; const target = els[Math.max(0, Math.min(els.length - 1, (delta > 0 ? idx : idx - 2)))]; if (target) { const id = target.dataset.msgId!; setExpanded((x) => ({ ...x, [id]: true })); target.scrollIntoView({ block: "start", behavior: "smooth" }); } }; window.addEventListener("ihm:reply", onReply); window.addEventListener("ihm:msg-nav", onNav); return () => { window.removeEventListener("ihm:reply", onReply); window.removeEventListener("ihm:msg-nav", onNav); }; }, [messages, reply]); const subject = messages[0]?.subject || emails[thread?.emailIds[0] ?? ""]?.subject || "(no subject)"; const rowIds = thread ? thread.emailIds.filter((id) => emails[id]) : []; const anyUnread = messages.some((e) => !e.keywords.$seen); const anyStarred = messages.some((e) => e.keywords.$flagged); const inJunk = Boolean(mailboxId && mailboxes[mailboxId]?.role === "junk"); const threadLabels = labels.filter((l) => messages.some((m) => m.keywords[l.keyword])); const threadMailboxes = useMemo(() => { const set = new Set(); for (const m of messages) for (const id of Object.keys(m.mailboxIds)) if (mailboxes[id] && id !== mailboxId) set.add(mailboxes[id]!.name); return [...set]; }, [messages, mailboxes, mailboxId]); const last = messages[messages.length - 1]; const accountId = useMail((s) => s.accountId); return (
} label={anyStarred ? "Remove star" : "Add star"} onClick={() => void actions.star(!anyStarred, rowIds)} /> } label="Label…" onClick={() => setLabelAnchor({ x: window.innerWidth / 2, y: 100 })} /> : } label={allExpanded ? "Collapse all" : "Expand all"} onClick={() => { setAllExpanded((v) => !v); setExpanded({}); }} /> } label="Print conversation" onClick={() => window.print()} /> {last && accountId && ( } label="Download latest as .eml" onClick={() => { const a = document.createElement("a"); a.href = client.downloadUrl(accountId, last.blobId, `${(last.subject || "message").replace(/[^\w.-]+/g, "_")}.eml`, "message/rfc822"); a.download = ""; a.click(); }} /> )}

{subject}

{(threadLabels.length > 0 || threadMailboxes.length > 0) && (
{threadMailboxes.map((n) => {n})} {threadLabels.map((l) => {l.name})}
)}
{messages.length > 1 && {messages.length} messages}
{error &&
{error}
} {loading && !messages.length && } {messages.map((e, i) => ( setExpanded((x) => ({ ...x, [e.id]: !isExpanded(e) }))} isLast={i === messages.length - 1} actions={actions} /> ))} {last && (
)}
{labelAnchor && setLabelAnchor(null)} />}
); }