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 && (