Merge pull request #190 from Coffey-Labs/fix/print-single-message

Print the message you asked to print
This commit is contained in:
Coffey Labs
2026-09-01 19:49:13 -07:00
committed by GitHub
2 changed files with 48 additions and 2 deletions
+9
View File
@@ -1235,6 +1235,15 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
*/ */
.message { break-inside: auto; border: 1px solid var(--border); } .message { break-inside: auto; border: 1px solid var(--border); }
.message-head { break-inside: avoid; break-after: avoid; } .message-head { break-inside: avoid; break-after: avoid; }
/*
* "Print" in a message's own menu marks that card and prints it alone;
* "Print conversation" in the thread toolbar sets no mark and prints them
* all. The subject heading stays either way -- a printed
* message with no subject on it is a page nobody can file -- but the
* "3 messages" count beside it goes, since only one of them is on the page.
*/
.printing-one .message:not(.print-target) { display: none !important; }
.printing-one .thread-subject > .muted { display: none !important; }
.print-only { display: block; } .print-only { display: block; }
body { background: #fff; color: #000; } body { background: #fff; color: #000; }
} }
+39 -2
View File
@@ -44,6 +44,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
const settings = useSettings((s) => s.settings); const settings = useSettings((s) => s.settings);
const updateSettings = useSettings((s) => s.update); const updateSettings = useSettings((s) => s.update);
const reply = useCompose((s) => s.reply); const reply = useCompose((s) => s.reply);
const cardRef = useRef<HTMLElement>(null);
const [details, setDetails] = useState(false); const [details, setDetails] = useState(false);
const [showSource, setShowSource] = useState(false); const [showSource, setShowSource] = useState(false);
const [showHeaders, setShowHeaders] = useState(false); const [showHeaders, setShowHeaders] = useState(false);
@@ -146,11 +147,47 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
if (!expanded) onToggle(); if (!expanded) onToggle();
}; };
/*
* Print this message, not the conversation it happens to sit in.
*
* The card is inside the thread, so a bare window.print() prints every
* message on the page -- which is what the toolbar's "Print conversation"
* is for, and not what someone asks for from a single message's menu.
* The two marker classes let the print stylesheet drop the siblings for
* the duration; the subject heading stays, since a printed message with no
* subject on it is a page nobody can file.
*
* window.print() blocks until the dialog is dismissed, so clearing the
* marks after it returns is enough on its own; `afterprint` is there for a
* browser that ever makes it asynchronous, and running twice is harmless.
*/
const printThis = () => {
const card = cardRef.current;
if (!card) {
window.print();
return;
}
const root = document.documentElement;
const clear = () => {
root.classList.remove("printing-one");
card.classList.remove("print-target");
window.removeEventListener("afterprint", clear);
};
window.addEventListener("afterprint", clear);
root.classList.add("printing-one");
card.classList.add("print-target");
try {
window.print();
} finally {
clear();
}
};
return ( return (
/* `wasUnread` rather than `$seen`: the bar marks what was unread when the /* `wasUnread` rather than `$seen`: the bar marks what was unread when the
conversation was opened, and keeps marking it after the auto-mark-read conversation was opened, and keeps marking it after the auto-mark-read
timer has told the server otherwise. Losing it mid-read was half of #69. */ timer has told the server otherwise. Losing it mid-read was half of #69. */
<article className={`message ${expanded ? "" : "collapsed"} ${wasUnread ?? !e.keywords.$seen ? "unread-msg" : ""}`} data-msg-id={e.id} onClick={collapsedClick}> <article ref={cardRef} className={`message ${expanded ? "" : "collapsed"} ${wasUnread ?? !e.keywords.$seen ? "unread-msg" : ""}`} data-msg-id={e.id} onClick={collapsedClick}>
<header className="message-head" onClick={(ev) => { if (expanded && !(ev.target as HTMLElement).closest("button,a,.message-details")) onToggle(); }}> <header className="message-head" onClick={(ev) => { if (expanded && !(ev.target as HTMLElement).closest("button,a,.message-details")) onToggle(); }}>
<Avatar who={from ?? null} /> <Avatar who={from ?? null} />
<div className="who"> <div className="who">
@@ -200,7 +237,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
<MenuItem icon={<Eye size={16} />} label={translate("Show original")} onClick={() => void openSource()} /> <MenuItem icon={<Eye size={16} />} label={translate("Show original")} onClick={() => void openSource()} />
<MenuItem icon={<Code size={16} />} label={translate("Show headers")} onClick={() => setShowHeaders(true)} /> <MenuItem icon={<Code size={16} />} label={translate("Show headers")} onClick={() => setShowHeaders(true)} />
<MenuItem icon={<Download size={16} />} label={translate("Download (.eml)")} onClick={downloadEml} /> <MenuItem icon={<Download size={16} />} label={translate("Download (.eml)")} onClick={downloadEml} />
<MenuItem icon={<Printer size={16} />} label={translate("Print")} onClick={() => window.print()} /> <MenuItem icon={<Printer size={16} />} label={translate("Print")} onClick={printThis} />
<MenuItem icon={<Filter size={16} />} label={translate("Filter messages like this…")} onClick={() => setFilterOpen(true)} /> <MenuItem icon={<Filter size={16} />} label={translate("Filter messages like this…")} onClick={() => setFilterOpen(true)} />
{hasCalendar && <MenuItem icon={<CalendarPlus size={16} />} label={translate("Create event…")} onClick={() => void startAppointment(e, navigate).catch((err: unknown) => toast.error((err as Error).message))} />} {hasCalendar && <MenuItem icon={<CalendarPlus size={16} />} label={translate("Create event…")} onClick={() => void startAppointment(e, navigate).catch((err: unknown) => toast.error((err as Error).message))} />}
{from && ( {from && (