From a33260966c3fe2b491d2c911462165102c85546a Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 1 Sep 2026 19:46:44 -0700 Subject: [PATCH] Print the message you asked to print The Print in a message's own menu called window.print() bare, so it printed the whole conversation -- every message on the page -- which is what the toolbar's "Print conversation" is already for. Opening one message's menu and asking to print it is not a request for the other eleven. It now marks the card it was opened from and the print stylesheet drops the siblings for the duration. The subject heading stays: a printed message with no subject on it is a page nobody can file. The "3 messages" count beside it goes, since only one of them is on the paper. The label is left as "Print" rather than made more specific -- it is translated in all nine shipped catalogues, and the toolbar entry beside it already says "conversation". --- web/src/styles/app.css | 9 +++++++ web/src/views/mail/MessageView.tsx | 41 ++++++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 8a4395f..8a6fcb7 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1235,6 +1235,15 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); } */ .message { break-inside: auto; border: 1px solid var(--border); } .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; } body { background: #fff; color: #000; } } diff --git a/web/src/views/mail/MessageView.tsx b/web/src/views/mail/MessageView.tsx index 3817af7..4c50c4d 100644 --- a/web/src/views/mail/MessageView.tsx +++ b/web/src/views/mail/MessageView.tsx @@ -44,6 +44,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn const settings = useSettings((s) => s.settings); const updateSettings = useSettings((s) => s.update); const reply = useCompose((s) => s.reply); + const cardRef = useRef(null); const [details, setDetails] = useState(false); const [showSource, setShowSource] = useState(false); const [showHeaders, setShowHeaders] = useState(false); @@ -146,11 +147,47 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn 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 ( /* `wasUnread` rather than `$seen`: the bar marks what was unread when the 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. */ -
+
{ if (expanded && !(ev.target as HTMLElement).closest("button,a,.message-details")) onToggle(); }}>
@@ -200,7 +237,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn } label={translate("Show original")} onClick={() => void openSource()} /> } label={translate("Show headers")} onClick={() => setShowHeaders(true)} /> } label={translate("Download (.eml)")} onClick={downloadEml} /> - } label={translate("Print")} onClick={() => window.print()} /> + } label={translate("Print")} onClick={printThis} /> } label={translate("Filter messages like this…")} onClick={() => setFilterOpen(true)} /> {hasCalendar && } label={translate("Create event…")} onClick={() => void startAppointment(e, navigate).catch((err: unknown) => toast.error((err as Error).message))} />} {from && (