diff --git a/app/print.css b/app/print.css index 9cf992ad5..2a754311b 100644 --- a/app/print.css +++ b/app/print.css @@ -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); +} diff --git a/components/desktop/__tests__/desktop-host.test.tsx b/components/desktop/__tests__/desktop-host.test.tsx index 61f8a3b8d..76b83aaa3 100644 --- a/components/desktop/__tests__/desktop-host.test.tsx +++ b/components/desktop/__tests__/desktop-host.test.tsx @@ -6,6 +6,7 @@ import { readDesktopPrefs, resetDesktopPrefsCache, setDesktopPref } from '@/lib/ import { resetDesktopLanguages } from '@/lib/desktop/languages'; import { registerComposeFlush, resetComposeFlushRegistry } from '@/lib/compose-flush-registry'; import { useToastStore } from '@/stores/toast-store'; +import { closeFirstStartNotice, firstStartNoticesWaiting, resetFirstStartNotices } from '@/lib/first-start-notices'; import { useAuthStore } from '@/stores/auth-store'; import { useLocaleStore } from '@/stores/locale-store'; import { useOfflineStore } from '@/lib/offline/state'; @@ -116,6 +117,13 @@ describe('DesktopHost', () => { expect(push).toHaveBeenLastCalledWith('/contacts'); }); + it('Help > Settings opens settings as a page of this window', () => { + const shell = fakeBridge(); + render(); + shell.run('go.settings'); + expect(push).toHaveBeenLastCalledWith('/settings'); + }); + it('a link to a folder goes through the router, encoded', () => { const shell = fakeBridge(); render(); @@ -233,8 +241,10 @@ describe('DesktopHost', () => { }); describe('a language with no checker in the OS', () => { + beforeEach(() => resetFirstStartNotices()); afterEach(() => resetComposeFlushRegistry()); - const toasts = () => useToastStore.getState().toasts; + // A first-start notice above the message list, not a toast over the composer. + const toasts = () => firstStartNoticesWaiting(); it('the composer asks once, and only a yes opens the downloads', async () => { useToastStore.setState({ toasts: [] }); @@ -246,6 +256,7 @@ describe('DesktopHost', () => { expect(toasts()).toHaveLength(0); const stop = registerComposeFlush(async () => {}); await waitFor(() => expect(toasts()).toHaveLength(1)); + expect(useToastStore.getState().toasts).toHaveLength(0); expect(shell.allowDownload).not.toHaveBeenCalled(); act(() => toasts()[0].action!.onClick()); expect(shell.allowDownload).toHaveBeenCalledTimes(1); @@ -253,6 +264,24 @@ describe('DesktopHost', () => { stop(); }); + it("closing it says no, and it doesn't come back", async () => { + const shell = fakeBridge(); + render(); + await act(async () => {}); + const stop = registerComposeFlush(async () => {}); + shell.missing('nl-NL'); + await waitFor(() => expect(toasts()).toHaveLength(1)); + // Only asked: nothing decided until the user closes it. + expect(readDesktopPrefs().dictionaryDownload).toBe('ask'); + act(() => closeFirstStartNotice(toasts()[0].id)); + expect(readDesktopPrefs().dictionaryDownload).toBe('no'); + shell.missing('fr-FR'); + await act(async () => {}); + expect(toasts()).toHaveLength(0); + expect(shell.allowDownload).not.toHaveBeenCalled(); + stop(); + }); + it('never a second time', async () => { useToastStore.setState({ toasts: [] }); setDesktopPref('dictionaryDownload', 'no'); diff --git a/components/desktop/desktop-host.tsx b/components/desktop/desktop-host.tsx index 909ca657b..57d427302 100644 --- a/components/desktop/desktop-host.tsx +++ b/components/desktop/desktop-host.tsx @@ -25,7 +25,7 @@ import { useEmailStore } from "@/stores/email-store"; import { useLocaleStore } from "@/stores/locale-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useThemeStore } from "@/stores/theme-store"; -import { toast } from "@/stores/toast-store"; +import { showFirstStartNotice } from "@/lib/first-start-notices"; /** * The webmail's side of what every desktop window needs: runs menu commands nothing @@ -71,6 +71,8 @@ export function DesktopHost() { "go.calendar": () => routerRef.current.push("/calendar"), "go.contacts": () => routerRef.current.push("/contacts"), "go.files": () => routerRef.current.push("/files"), + // Help > Settings: a page of this window, as the rail's gear opens it. + "go.settings": () => routerRef.current.push("/settings"), "help.shortcuts": () => openKeyboardShortcuts(), }; return menu.onCommand((id) => { @@ -127,9 +129,10 @@ export function DesktopHost() { noteComposerLanguage("settings", writingLanguage && writingLanguage !== "auto" ? writingLanguage : null); }, [writingLanguage]); - // A language with no checker in the OS needs a dictionary from Google: the composer - // asks once, and never in no-third-party mode. Until a yes, the shell has nowhere to - // fetch from at all. + // A language with no checker in the OS needs a dictionary from Google: once a composer + // is open, a first-start notice asks, never in no-third-party mode, and not again once + // closed. Until a yes, the shell has nowhere to fetch from at all. Windows never asks: + // it checks spelling itself (the shell's context-menu.ts). useEffect(() => { const spellcheck = getDesktopBridge()?.spellcheck; if (!spellcheck) return; @@ -139,8 +142,6 @@ export function DesktopHost() { const [language] = waiting; if (!language || !composerIsOpen() || !dictionaryDownloadAllowed()) return; if (readDesktopPrefs().dictionaryDownload !== "ask") return; - // Asked once, whatever comes of it. - setDesktopPref("dictionaryDownload", "no"); const translate = tRef.current; let name = language; try { @@ -148,8 +149,9 @@ export function DesktopHost() { } catch { // The tag itself will do. } - toast.info(translate("desktopUi.spellcheck.offer", { language: name }), { - duration: 0, + showFirstStartNotice({ + id: "dictionary-download", + text: translate("desktopUi.spellcheck.offer", { language: name }), action: { label: translate("desktopUi.spellcheck.download"), onClick: () => { @@ -157,6 +159,10 @@ export function DesktopHost() { if (dictionaryDownloadAllowed()) spellcheck.allowDownload(); }, }, + // Closed without a yes: asked once, not again. + onClose: () => { + if (readDesktopPrefs().dictionaryDownload === "ask") setDesktopPref("dictionaryDownload", "no"); + }, }); }; const heard = (language: string) => { diff --git a/components/email/__tests__/viewer-print-copy.test.tsx b/components/email/__tests__/viewer-print-copy.test.tsx index 8df96af39..d5e020f5d 100644 --- a/components/email/__tests__/viewer-print-copy.test.tsx +++ b/components/email/__tests__/viewer-print-copy.test.tsx @@ -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(<> + + + ); + 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(); + 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(); expect(printCopy()).toBeNull(); diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index 5a14eb71d..0f29cd8b0 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -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 && ( + ` : summary.organizer) : ''], + ] as Array<[string, string]>).filter(([, value]) => value), + }} + /> + )} {/* Avatar-style icon */}
/g, '
') : 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; diff --git a/components/email/message-print.tsx b/components/email/message-print.tsx index 53cfd7c44..43aff624d 100644 --- a/components/email/message-print.tsx +++ b/components/email/message-print.tsx @@ -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