fix: print an invitation with its date, time, place and organiser

This commit is contained in:
Linus Rath
2026-11-04 07:05:31 +01:00
parent 0729f05aeb
commit 2fcb7cd544
6 changed files with 116 additions and 0 deletions
+21
View File
@@ -738,3 +738,24 @@
margin: 0;
overflow-wrap: anywhere;
}
/* An invitation's card: when and where, in a box above the body. */
.pr-invitation {
margin: 4mm 0 0;
padding: 2.5mm 3mm;
border: 0.3mm solid var(--pr-rule, #bbb);
border-radius: 1.5mm;
break-inside: avoid;
}
.pr-invitation-heading {
margin: 0;
font-size: 8pt;
font-weight: 600;
color: var(--pr-muted);
}
.pr-invitation .pr-message-meta {
font-size: 10pt;
color: var(--pr-ink);
}
@@ -61,6 +61,26 @@ function printCopy(): HTMLElement | null {
}
describe('Ctrl+P on an open message', () => {
it('prints an invitation with its card: when, where and who', async () => {
const { InvitationPrintSync } = await import('@/lib/print/invitation-print');
const card = { heading: 'Calendar Invitation', rows: [['Time', 'Thu 5 Nov, 13:00 to 14:00'], ['Location', 'Room 4'], ['Organized by', 'Anna Becker']] as Array<[string, string]> };
const view = render(<>
<EmailViewer email={htmlMail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />
<InvitationPrintSync emailId="e1" print={card} />
</>);
act(() => { window.dispatchEvent(new Event('beforeprint')); });
const box = printCopy()!.querySelector('[data-message-print-invitation]')!;
expect(box.textContent).toContain('Calendar Invitation');
expect(box.textContent).toContain('Thu 5 Nov, 13:00 to 14:00');
expect(box.textContent).toContain('Room 4');
expect(box.textContent).toContain('Anna Becker');
act(() => { window.dispatchEvent(new Event('afterprint')); });
// The card gone (another message opened): the next printout has no box.
view.rerender(<EmailViewer email={htmlMail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
act(() => { window.dispatchEvent(new Event('beforeprint')); });
expect(printCopy()!.querySelector('[data-message-print-invitation]')).toBeNull();
}, 30_000);
it('prints the message alone, next to the app', () => {
render(<EmailViewer email={htmlMail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
expect(printCopy()).toBeNull();
@@ -20,6 +20,7 @@ import {
ChevronUp,
} from '@/components/icons';
import { useTranslations, useFormatter } from 'next-intl';
import { InvitationPrintSync } from '@/lib/print/invitation-print';
import { useRouter } from '@/i18n/navigation';
import { isDocumentRTL } from '@/i18n/direction';
import { useAuthStore } from '@/stores/auth-store';
@@ -939,6 +940,21 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
tabIndex={isCollapsed ? 0 : undefined}
aria-expanded={isCollapsed ? false : undefined}
>
{/* When and where, for the printout of this message: its print copy has no card. */}
{summary && (
<InvitationPrintSync
emailId={email.id}
print={{
heading: bannerTitle,
rows: ([
[t('change_title'), summary.title ?? ''],
[t('change_time'), summary.start ? formatSchedule(summary) : ''],
[t('change_location'), summary.location ?? ''],
[t('organizer_label'), summary.organizer ? (summary.organizerEmail && summary.organizerEmail !== summary.organizer ? `${summary.organizer} <${summary.organizerEmail}>` : summary.organizer) : ''],
] as Array<[string, string]>).filter(([, value]) => value),
}}
/>
)}
{/* Avatar-style icon */}
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm",
+4
View File
@@ -17,6 +17,7 @@ import { ReaderAttachments, type AttachmentDragProps } from "./reader-attachment
import { editAccountOf, useEditAttachment } from "@/hooks/use-edit-attachment";
import { ReaderViewDialog } from "./reader-view-dialog";
import { MessagePrint, type MessagePrintFields } from "./message-print";
import { useInvitationPrint } from "@/lib/print/invitation-print";
import { usePrintListener, usePrinting } from "@/components/print/print-root";
import { ReadingMask } from "./reading-mask";
import { collectReferencedCids, isEmbeddedInBody, isReportPart, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility";
@@ -3798,6 +3799,7 @@ export function EmailViewer({
? effectiveEmailContent.html.replace(/<details>/g, '<details open>')
: sanitizeEmailHtml(effectiveEmailContent.html),
plain,
invitation: invitationPrint,
};
};
@@ -3895,6 +3897,8 @@ ${printWatermark ? printWatermark.html : ''}
() => printing && !!viewerRootEl && isShownOnScreen(viewerRootEl),
[printing, viewerRootEl],
);
// An invitation's when and where, from its card.
const invitationPrint = useInvitationPrint(email?.id);
const printCopyFields = printShown && !devices.noPrint && !isLoading && !!email?.bodyValues
? messagePrintFields()
: null;
+16
View File
@@ -3,6 +3,7 @@
import { useCallback } from "react";
import { useTranslations } from "next-intl";
import { PrintRoot } from "@/components/print/print-root";
import type { InvitationPrint } from "@/lib/print/invitation-print";
export interface MessagePrintFields {
subject: string;
@@ -13,6 +14,8 @@ export interface MessagePrintFields {
/** Sanitized body HTML without <style>: a re-sanitized HTML body, or the escaped plain-text one. */
bodyHtml: string;
plain: boolean;
/** An invitation's card: what, when, where and who, as the reader shows it. */
invitation?: InvitationPrint | null;
}
// The reader's image rule: cap only images that set no max-width of their own.
@@ -66,6 +69,19 @@ export function MessagePrint({ fields }: { fields: MessagePrintFields }) {
))}
</dl>
</header>
{fields.invitation && fields.invitation.rows.length > 0 && (
<section className="pr-invitation" data-message-print-invitation="">
<h2 className="pr-invitation-heading">{fields.invitation.heading}</h2>
<dl className="pr-message-meta">
{fields.invitation.rows.map(([label, value]) => (
<div key={label}>
<dt>{label}:</dt>
<dd dir="auto">{value}</dd>
</div>
))}
</dl>
</section>
)}
<div className="pr-message-body" data-message-print-body="" ref={fillBody} />
</article>
</PrintRoot>
+39
View File
@@ -0,0 +1,39 @@
/**
* What an invitation's card says, for the printout of its message. The card works its
* details out itself (components/email/calendar-invitation-banner.tsx); the print copy
* (components/email/message-print.tsx) only shows the header and the body, so without
* this a printed invitation lost when and where, the part people print it for.
*/
import { useEffect } from 'react';
import { create } from 'zustand';
export interface InvitationPrint {
heading: string;
rows: Array<[label: string, value: string]>;
}
const useInvitations = create<Record<string, InvitationPrint>>(() => ({}));
export function setInvitationPrint(emailId: string, print: InvitationPrint | null): void {
useInvitations.setState((state) => {
const next = { ...state };
if (print) next[emailId] = print;
else delete next[emailId];
return next;
}, true);
}
export function useInvitationPrint(emailId: string | undefined): InvitationPrint | null {
return useInvitations((state) => (emailId ? state[emailId] ?? null : null));
}
/** Rendered by the card while its details are known; takes them away again when it goes. */
export function InvitationPrintSync({ emailId, print }: { emailId: string; print: InvitationPrint }): null {
const key = JSON.stringify(print);
useEffect(() => {
setInvitationPrint(emailId, JSON.parse(key) as InvitationPrint);
return () => setInvitationPrint(emailId, null);
}, [emailId, key]);
return null;
}