Files
ihasmail-inbuxa/web/src/views/mail/ThreadView.tsx
T
jcoffey-dev ac4c4bd267 Check component props for untranslated literals, and fail on a finding
i18n-literals checked title, aria-label, placeholder and alt on elements,
but not the same English passed to a component, so a MenuItem label or a
Popover ariaLabel written as a literal went through. It also excused a
literal that happened to be a catalogue key. That exemption is meant for
English held in a constant and translated where it renders, and a literal
written straight into a JSX attribute has no such render site. No
component passes its props through t(). And neither half of i18n:check
was run with --check, so a finding printed and the script still exited 0.

Component props are checked now, a key no longer excuses a literal in an
attribute, and both scripts run with --check. That found 28 strings
rendering English in every language: 19 already had keys and are wrapped,
and 9 are new keys in all nine catalogues. The contact editor's Save and
Saving… buttons are wrapped as well, on the same line.
2026-09-14 08:03:12 -07:00

324 lines
17 KiB
TypeScript

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<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;
// 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<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)";
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" 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>
);
}