import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AlertOctagon, Archive, ArrowLeft, ChevronDown, ChevronUp, FolderInput, Forward, Mail, MailOpen, MailPlus, MoreVertical, Printer, Reply, ReplyAll, ShieldCheck, Star, Tag, Trash2, Download , Paperclip} from "lucide-react"; import { useMail } from "@/store/mail"; import { visibleMessages } from "@/lib/openMessage"; 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, useIsNarrow, useIsTouch } from "@/ui/misc"; import { client } from "@/jmap/client"; import { LabelPicker } from "./LabelPicker"; import { threadScrollTarget } from "@/lib/threadScroll"; import { useEdgeBack } from "@/lib/touch"; import { plural, t } from "@/lib/i18n"; /** How long the opening scroll keeps its place while bodies and images land. */ const HOLD_MS = 2000; interface Props { threadId: Id; mailboxId: Id | null; onBack: () => void; actions: ListActions; onNavigate: (delta: number) => void; hasPrev: boolean; hasNext: boolean; /** * With conversation view off, the single message to show. The thread is still * what loads -- one request, and the reply/forward paths keep the context * they need -- but only this message is rendered. */ messageId?: Id | null; } export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, hasPrev, hasNext, messageId = null }: 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(); /** The overflow on the reply strip, which is the only per-message menu a phone offers easily. */ const replyMore = useMenu(); const scrollRef = useRef(null); const markTimer = useRef(null); const isTouch = useIsTouch(); const narrow = useIsNarrow(); /* * Drag in from the left edge to go back to the list. * * Only where back means something: on a wide screen the list is still * beside the conversation and there is nowhere to go. The toolbar's arrow * stays regardless — a gesture with no visible control is a gesture only * the people who already know about it can use. */ const [viewEl, setViewEl] = useState(null); useEdgeBack(viewEl, onBack, isTouch && narrow); // 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; }); const shown = filtered.length ? filtered : all; return visibleMessages(shown, messageId).sort((a, b) => a.receivedAt.localeCompare(b.receivedAt)); }, [thread, emails, fullIds, mailboxId, messageId]); /* * 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). * * Scrolling once is not enough. Message bodies are written into shadow roots * by child effects, and the images in them load later still, so the pane goes * on growing after the scroll -- and `scrollIntoView` clamps to the scroll * range as it stands the moment it is called. The read-thread fallback always * aims at the last message, which no thread has the room to lift to the top, * so that clamp is the whole of the range: measuring it before the images * landed stopped 39px short of the bottom, every time (#89). * * So the target is held against the top of the pane while the thread settles, * and let go the moment the reader touches it. A pane that re-scrolls under * someone who has started reading is worse than one that lands short, which * is why the hold ends on the first sign of them rather than when the content * stops changing. */ useEffect(() => { const sc = scrollRef.current; if (!messages.length || !sc) return; const target = threadScrollTarget(messages, wasUnread); if (!target) return; let held = true; const align = () => { if (held) sc.querySelector(`[data-msg-id="${CSS.escape(target)}"]`)?.scrollIntoView({ block: "start" }); }; const release = () => { held = false; }; align(); // The messages and the reply box: what grows is one of their heights. const ro = new ResizeObserver(align); for (const child of sc.children) ro.observe(child); // `scroll` is not in here: the aligning does that itself. for (const ev of ["wheel", "pointerdown", "touchstart"]) sc.addEventListener(ev, release, { passive: true }); window.addEventListener("keydown", release); const settled = window.setTimeout(release, HOLD_MS); return () => { release(); ro.disconnect(); for (const ev of ["wheel", "pointerdown", "touchstart"]) sc.removeEventListener(ev, release); window.removeEventListener("keydown", release); window.clearTimeout(settled); }; // 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 ? t("Remove star") : t("Add star")} onClick={() => void actions.star(!anyStarred, rowIds)} /> } label={t("Label…")} onClick={() => setLabelAnchor({ x: window.innerWidth / 2, y: 100 })} /> : } label={allExpanded ? t("Collapse all") : t("Expand all")} onClick={() => { setAllExpanded((v) => !v); setExpanded({}); }} /> } label={t("Print conversation")} onClick={() => window.print()} /> {last && accountId && ( } label={t("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 && {plural(messages.length, { one: "{n} message", other: "{n} 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 && (
{/* On a phone this strip is where a thumb goes, and the per-message menu at the top of a card is not somewhere anybody looks for "send this again" -- which is how compose-as-new came to be reported missing on mobile when it was there all along (#181). A fourth full button does not fit at 500px; this does, and it spells the action out once opened. */} } label={t("Forward as attachment")} onClick={() => useCompose.getState().forwardAsAttachment(last)} /> } label={t("Compose as new")} onClick={() => void useCompose.getState().composeAsNew(last)} />
)}
{labelAnchor && setLabelAnchor(null)} />}
); }