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.
324 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|