360 lines
19 KiB
TypeScript
360 lines
19 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useViewingDelegation } from "@/store/session";
|
|
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/input/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<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();
|
|
/** The overflow on the reply strip, which is the only per-message menu a phone offers easily. */
|
|
const replyMore = useMenu();
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const markTimer = useRef<number | null>(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<HTMLDivElement | null>(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<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;
|
|
|
|
/*
|
|
* Which messages have been read while this conversation was open.
|
|
*
|
|
* Mark as unread on an open message used to undo itself: clearing `$seen`
|
|
* re-ran the timer below, which found an expanded unread message and marked
|
|
* it read again a moment later (Gitea issue #25). A message that has been
|
|
* read here and is unread again was made so on purpose -- by this reader or
|
|
* on another device -- so the timer leaves it alone until the conversation
|
|
* is opened again. Mail that arrives unread was never read here, and is
|
|
* still marked read as before. Accumulated during render, like the set
|
|
* above.
|
|
*/
|
|
const readWhileOpen = useRef<{ key: Id | null; ids: Set<Id> }>({ key: null, ids: new Set() });
|
|
if (readWhileOpen.current.key !== threadKey) readWhileOpen.current = { key: threadKey, ids: new Set() };
|
|
for (const m of messages) if (m.keywords.$seen) readWhileOpen.current.ids.add(m.id);
|
|
|
|
// 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
|
|
// inbuxa AL-6: never in a locked account handed over to read: reading it
|
|
// changes nothing there, not even $seen
|
|
const readOnly = useViewingDelegation()?.access === "read";
|
|
useEffect(() => {
|
|
if (!messages.length || readOnly) return;
|
|
const unread = messages.filter((e) => !e.keywords.$seen && isExpanded(e) && !readWhileOpen.current.ids.has(e.id)).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, readOnly]);
|
|
|
|
/*
|
|
* 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<HTMLElement>(`[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<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)";
|
|
/*
|
|
* What the toolbar acts on: the messages the pane is showing, not the thread
|
|
* they belong to.
|
|
*
|
|
* With conversation view off, opening a message opens that message -- the
|
|
* list shows it alone, the pane renders it alone, and the buttons above it
|
|
* said so, because `anyUnread` and the rest already read `messages`. Only the
|
|
* ids handed to the action still named the whole thread, so Mark as unread,
|
|
* Move to, Report spam and Delete quietly took every message in it (#414).
|
|
*
|
|
* Same fallback as the pane's: an id naming nothing in this thread means the
|
|
* whole conversation, so the buttons keep matching what is on screen.
|
|
*/
|
|
const rowIds = useMemo(() => {
|
|
const loaded = thread ? thread.emailIds.filter((id) => emails[id]).map((id) => ({ id })) : [];
|
|
return visibleMessages(loaded, messageId).map((m) => m.id);
|
|
}, [thread, emails, messageId]);
|
|
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" ref={setViewEl}>
|
|
<div className="thread-toolbar">
|
|
<button className="icon-btn" onClick={onBack} aria-label={t("Back to list")} title={t("Back (u)")}>
|
|
<ArrowLeft size={20} />
|
|
</button>
|
|
<button className="icon-btn" title={t("Archive (e)")} onClick={() => void actions.archive(rowIds)}><Archive size={19} /></button>
|
|
<button className="icon-btn" title={inJunk ? t("Not spam") : t("Report spam (!)")} onClick={() => void actions.spam(rowIds)}>{inJunk ? <ShieldCheck size={19} /> : <AlertOctagon size={19} />}</button>
|
|
<button className="icon-btn" title={t("Delete (#)")} onClick={() => void actions.trash(rowIds)}><Trash2 size={19} /></button>
|
|
<span className="tb-sep hide-mobile" />
|
|
<button className="icon-btn hide-mobile" title={anyUnread ? t("Mark as read") : t("Mark as unread")} onClick={() => void actions.read(anyUnread, rowIds)}>{anyUnread ? <MailOpen size={19} /> : <Mail size={19} />}</button>
|
|
<button className="icon-btn hide-mobile" title={t("Move to (v)")} onClick={() => actions.move(rowIds)}><FolderInput size={19} /></button>
|
|
<button className="icon-btn hide-mobile" title={t("Labels (l)")} onClick={(e) => setLabelAnchor({ x: e.clientX, y: e.clientY })}><Tag size={19} /></button>
|
|
<button className="icon-btn" onClick={moreMenu.open} aria-label={t("More")}><MoreVertical size={19} /></button>
|
|
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="start" width={240}>
|
|
<MenuItem icon={<Star size={16} />} label={anyStarred ? t("Remove star") : t("Add star")} onClick={() => void actions.star(!anyStarred, rowIds)} />
|
|
<MenuItem icon={<Tag size={16} />} label={t("Label…")} onClick={() => setLabelAnchor({ x: window.innerWidth / 2, y: 100 })} />
|
|
<MenuItem icon={allExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />} label={allExpanded ? t("Collapse all") : t("Expand all")} onClick={() => { setAllExpanded((v) => !v); setExpanded({}); }} />
|
|
<MenuSep />
|
|
<MenuItem icon={<Printer size={16} />} label={t("Print conversation")} onClick={() => window.print()} />
|
|
{last && accountId && (
|
|
<MenuItem icon={<Download size={16} />} 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(); }} />
|
|
)}
|
|
</Popover>
|
|
<div className="thread-nav hide-mobile">
|
|
<button className="icon-btn sm" disabled={!hasPrev} onClick={() => onNavigate(-1)} title={t("Newer (k)")}><ChevronUp size={18} /></button>
|
|
<button className="icon-btn sm" disabled={!hasNext} onClick={() => onNavigate(1)} title={t("Older (j)")}><ChevronDown size={18} /></button>
|
|
</div>
|
|
</div>
|
|
<div className="thread-scroll" ref={scrollRef}>
|
|
<div className="thread-subject">
|
|
<div className="grow">
|
|
<h1 className="notranslate" translate="no">{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 }}>{plural(messages.length, { one: "{n} message", other: "{n} messages" })}</span>}
|
|
</div>
|
|
{error && <div className="error-box" style={{ margin: 16 }}>{error}</div>}
|
|
{loading && !messages.length && <Spinner label={t("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} /> {t("Reply")}</button>
|
|
<button onClick={() => void reply(last, "replyAll")}><ReplyAll size={16} /> {t("Reply all")}</button>
|
|
<button onClick={() => void reply(last, "forward")}><Forward size={16} /> {t("Forward")}</button>
|
|
{/*
|
|
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.
|
|
*/}
|
|
<span className="spacer" />
|
|
<button className="icon-btn" onClick={replyMore.open} aria-label={t("More ways to send this")}><MoreVertical size={18} /></button>
|
|
<Popover anchor={replyMore.anchor} onClose={replyMore.close} align="end" width={220}>
|
|
<MenuItem icon={<Paperclip size={16} />} label={t("Forward as attachment")} onClick={() => useCompose.getState().forwardAsAttachment(last)} />
|
|
<MenuItem icon={<MailPlus size={16} />} label={t("Compose as new")} onClick={() => void useCompose.getState().composeAsNew(last)} />
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{labelAnchor && <LabelPicker ids={rowIds} anchor={labelAnchor} onClose={() => setLabelAnchor(null)} />}
|
|
</div>
|
|
);
|
|
}
|