Files
ihasmail/web/src/views/mail/ThreadView.tsx
T
jcoffey-dev d64249b46d Open a conversation on its first unread message
Selecting a thread put you at the newest message. Anything unread above
that sat off the top of the pane with nothing to announce it, and the
only way to find out was to scroll up -- by which time the auto-mark-read
timer had marked the whole thread read anyway, so scrolling up meant
scrolling up to mail already counted as seen (#87).

Opening at the bottom is right when there is nothing to catch up on and
wrong the moment there is. The pane now opens on the oldest message that
was unread when the thread was opened, and falls back to the newest when
the thread has already been read.

mbunkus's out-of-order case is the one that rules out guessing at a
position. A participant whose server could not connect for hours
delivers a message long after it was written, and it lands in the middle
of a conversation that has already moved past it -- so "second to last",
or any other fixed offset from the end, finds nothing. Reading the
unread set is the only thing that does.

Two cases leave the pane where it is:

  - a single message, which is already the whole pane
  - the first unread being the first message, where the top of the pane
    shows it anyway, together with the subject; scrolling to it would
    push the subject off for nothing

It reads the set captured when the thread was opened rather than live
`$seen` state, for the same reason expansion does (#69): the mark-read
timer must not change the shape of what you are looking at. That also
makes the landing stable, because everything above the first unread
message is a collapsed row of fixed height -- nothing up there reflows
after the scroll.

The mock grows a thread that reproduces it: seven messages with the
unread one second, four more behind it. Verified against it. Opening the
thread lands the unread message flush against the top of the pane at
scrollTop 158; the old scroll to the newest message put it at 445, with
287px of the message -- header, sender and unread bar included -- above
the fold. On a thread whose first message is the unread one the pane
stays at 0 with the subject in view, where before it would have scrolled
333. Once the thread is read, reopening it goes back to the newest
message.
2026-08-27 06:35:26 -07:00

242 lines
13 KiB
TypeScript

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<string | null>(null);
const [expanded, setExpanded] = useState<Record<Id, boolean>>({});
const [allExpanded, setAllExpanded] = useState(false);
const [labelAnchor, setLabelAnchor] = useState<{ x: number; y: number } | null>(null);
const moreMenu = useMenu();
const scrollRef = useRef<HTMLDivElement>(null);
const markTimer = useRef<number | null>(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<Id> }>({ 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<HTMLElement>(`[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<number>).detail;
const els = Array.from(scrollRef.current?.querySelectorAll<HTMLElement>("[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<string>();
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 (
<div className="thread-view">
<div className="thread-toolbar">
<button className="icon-btn" onClick={onBack} aria-label="Back to list" title="Back (u)">
<ArrowLeft size={20} />
</button>
<button className="icon-btn" title="Archive (e)" onClick={() => void actions.archive(rowIds)}><Archive size={19} /></button>
<button className="icon-btn" title={inJunk ? "Not spam" : "Report spam (!)"} onClick={() => void actions.spam(rowIds)}>{inJunk ? <ShieldCheck size={19} /> : <AlertOctagon size={19} />}</button>
<button className="icon-btn" title="Delete (#)" onClick={() => void actions.trash(rowIds)}><Trash2 size={19} /></button>
<span className="tb-sep hide-mobile" />
<button className="icon-btn hide-mobile" title={anyUnread ? "Mark as read" : "Mark as unread"} onClick={() => void actions.read(anyUnread, rowIds)}>{anyUnread ? <MailOpen size={19} /> : <Mail size={19} />}</button>
<button className="icon-btn hide-mobile" title="Move to (v)" onClick={() => actions.move(rowIds)}><FolderInput size={19} /></button>
<button className="icon-btn hide-mobile" title="Labels (l)" onClick={(e) => setLabelAnchor({ x: e.clientX, y: e.clientY })}><Tag size={19} /></button>
<button className="icon-btn" onClick={moreMenu.open} aria-label="More"><MoreVertical size={19} /></button>
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="start" width={240}>
<MenuItem icon={<Star size={16} />} label={anyStarred ? "Remove star" : "Add star"} onClick={() => void actions.star(!anyStarred, rowIds)} />
<MenuItem icon={<Tag size={16} />} label="Label…" onClick={() => setLabelAnchor({ x: window.innerWidth / 2, y: 100 })} />
<MenuItem icon={allExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />} label={allExpanded ? "Collapse all" : "Expand all"} onClick={() => { setAllExpanded((v) => !v); setExpanded({}); }} />
<MenuSep />
<MenuItem icon={<Printer size={16} />} label="Print conversation" onClick={() => window.print()} />
{last && accountId && (
<MenuItem icon={<Download size={16} />} 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(); }} />
)}
</Popover>
<div className="thread-nav hide-mobile">
<button className="icon-btn sm" disabled={!hasPrev} onClick={() => onNavigate(-1)} title="Newer (k)"><ChevronUp size={18} /></button>
<button className="icon-btn sm" disabled={!hasNext} onClick={() => onNavigate(1)} title="Older (j)"><ChevronDown size={18} /></button>
</div>
</div>
<div className="thread-scroll" ref={scrollRef}>
<div className="thread-subject">
<div className="grow">
<h1>{subject}</h1>
{(threadLabels.length > 0 || threadMailboxes.length > 0) && (
<div className="labels">
{threadMailboxes.map((n) => <span key={n} className="chip">{n}</span>)}
{threadLabels.map((l) => <span key={l.keyword} className="tag" style={{ background: l.color }}>{l.name}</span>)}
</div>
)}
</div>
{messages.length > 1 && <span className="muted small nowrap" style={{ marginTop: 6 }}>{messages.length} messages</span>}
</div>
{error && <div className="error-box" style={{ margin: 16 }}>{error}</div>}
{loading && !messages.length && <Spinner label="Loading conversation…" />}
{messages.map((e, i) => (
<MessageView
key={e.id}
email={e}
expanded={isExpanded(e)}
wasUnread={wasUnread.has(e.id)}
onToggle={() => setExpanded((x) => ({ ...x, [e.id]: !isExpanded(e) }))}
isLast={i === messages.length - 1}
actions={actions}
/>
))}
{last && (
<div className="reply-box">
<div className="reply-prompt">
<button onClick={() => void reply(last, "reply")}><Reply size={16} /> Reply</button>
<button onClick={() => void reply(last, "replyAll")}><ReplyAll size={16} /> Reply all</button>
<button onClick={() => void reply(last, "forward")}><Forward size={16} /> Forward</button>
</div>
</div>
)}
</div>
{labelAnchor && <LabelPicker ids={rowIds} anchor={labelAnchor} onClose={() => setLabelAnchor(null)} />}
</div>
);
}