diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 9164a715f..305421961 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -33,6 +33,9 @@ import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt import { SessionTimeoutGuard } from "@/components/session/session-timeout-guard"; import { AccountTemplateSync } from "@/components/templates/account-template-sync"; import { FreeBusyAutoShare } from "@/components/calendar/free-busy-auto-share"; +import { MainWindowOnly } from "@/components/popout/main-window-only"; +import { PopoutHost } from "@/components/popout/popout-host"; +import { WindowSyncHost } from "@/components/popout/window-sync-host"; import { IS_LITE } from "@/lib/lite"; import { setRequestLocale } from "next-intl/server"; import { locales } from "@/i18n/routing"; @@ -78,16 +81,22 @@ export default async function LocaleLayout({ - - + + + + + {!IS_LITE && } + + {!IS_LITE && } - {children} - + + + @@ -98,15 +107,19 @@ export default async function LocaleLayout({ - - - + + + + + {/* Offline mode isn't in Lite (no service worker there). */} {!IS_LITE && } - - {/* Lite is a static bundle: no server keeps an archive there. */} - {!IS_LITE && } + + + {/* Lite is a static bundle: no server keeps an archive there. */} + {!IS_LITE && } + diff --git a/app/(main)/[locale]/popout/compose/page.tsx b/app/(main)/[locale]/popout/compose/page.tsx new file mode 100644 index 000000000..88ab7d524 --- /dev/null +++ b/app/(main)/[locale]/popout/compose/page.tsx @@ -0,0 +1,7 @@ +import { PopoutComposeApp } from "@/components/popout/popout-compose-app"; + +// The composer in a window of its own (lib/popout). Not in Lite: scripts/lite +// removes this tree, and "Open in new window" isn't offered there. +export default function PopoutComposePage() { + return ; +} diff --git a/app/(main)/[locale]/popout/message/[id]/page.tsx b/app/(main)/[locale]/popout/message/[id]/page.tsx new file mode 100644 index 000000000..806370780 --- /dev/null +++ b/app/(main)/[locale]/popout/message/[id]/page.tsx @@ -0,0 +1,8 @@ +import { PopoutMessageApp } from "@/components/popout/popout-message-app"; + +// One message in a window of its own (lib/popout). Not in Lite: scripts/lite +// removes this tree, since a static export can't list every message id. +export default async function PopoutMessagePage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ; +} diff --git a/app/(main)/[locale]/pro/page.tsx b/app/(main)/[locale]/pro/page.tsx index a1015bd64..127deb308 100644 --- a/app/(main)/[locale]/pro/page.tsx +++ b/app/(main)/[locale]/pro/page.tsx @@ -509,7 +509,7 @@ export default function ProHome() { return ( {/* data-pro-shell: a page skeleton inside a pane draws its own frame, not the Pro one. */} -
+
{/* Once for the whole shell: email and folder tabs have no app strip, and split panes would show it twice. */}
diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index d186b72da..e2704bcc8 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -20,8 +20,11 @@ import { locales, defaultLocale } from "@/i18n/routing"; import { IS_LITE, LITE_MOUNT_GLOBAL } from "@/lib/lite"; import { accessibilityBootScript } from "@/lib/accessibility-settings"; import { bootSkeletonScript } from "@/lib/boot-skeleton/boot-script"; +import { WindowTitleBar } from "@/components/layout/window-title-bar"; import "../globals.css"; import "../print.css"; +import "../desktop.css"; +import { desktopBootScript } from "@/lib/desktop/boot"; // This layout renders and sits ABOVE the [locale] segment, so // next-intl's getLocale() returns the default locale here - emitting @@ -308,6 +311,7 @@ export default async function RootLayout({ dangerouslySetInnerHTML={{ __html: ` (function() {${IS_LITE ? liteLocaleBootstrap() : ""} + ${desktopBootScript()} try { const stored = localStorage.getItem('theme-storage'); const theme = stored ? JSON.parse(stored).state.theme : 'system'; @@ -330,6 +334,7 @@ export default async function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} ${hankenGrotesk.variable} antialiased`} > + {!themeColorConfigured && } diff --git a/app/desktop.css b/app/desktop.css new file mode 100644 index 000000000..37cff0945 --- /dev/null +++ b/app/desktop.css @@ -0,0 +1,64 @@ +/* + * The desktop app is an app, not a page. Everything here hangs on + * html[data-desktop], which the boot script sets when the preload bridge is + * there (lib/desktop/boot.ts), so a browser never matches any of it. + * + * UI chrome is listed one by one, instead of switching selection off for the + * whole page and listing what may stay on: a message, a contact's details or + * an event's notes must not stop being selectable because nobody thought of + * them. Message bodies are in their own frame and aren't touched at all. + */ + +html[data-desktop], +html[data-desktop] body { + overscroll-behavior: none; +} + +html[data-desktop] :is( + button, + summary, + label, + nav, + [role="button"], + [role="navigation"], + [role="toolbar"], + [role="menu"], + [role="menubar"], + [role="menuitem"], + [role="menuitemcheckbox"], + [role="menuitemradio"], + [role="tab"], + [role="tablist"], + [role="listbox"], + [role="option"], + [role="tree"], + [role="treeitem"], + [role="row"], + [data-email-id], + [data-composer-header], + [data-mv-compose-titlebar], + [data-window-title-bar] +) { + -webkit-user-select: none; + user-select: none; + cursor: default; +} + +/* A field inside a label or a row is still a field. */ +html[data-desktop] :is(input, textarea, select, [contenteditable="true"], [contenteditable="plaintext-only"]) { + -webkit-user-select: text; + user-select: text; +} + +html[data-desktop] :is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="button"], [type="submit"]), textarea, [contenteditable="true"], [contenteditable="plaintext-only"]) { + cursor: text; +} + +/* No dragging an icon or a link by accident. What the app means to drag says so. */ +html[data-desktop] :is(img, a, svg) { + -webkit-user-drag: none; +} + +html[data-desktop] [draggable="true"] { + -webkit-user-drag: element; +} diff --git a/app/globals.css b/app/globals.css index 7cbfd50c8..212b080c2 100644 --- a/app/globals.css +++ b/app/globals.css @@ -568,6 +568,25 @@ optgroup { /* Mobile Responsive Utilities */ +/* + * The top of the page in an installed app without a native title bar. 0 + * without one: then --app-top-inset is exactly env(safe-area-inset-top), what + * the page roots used before. env() and not the display-mode media query, + * which is false in Electron with titleBarOverlay. Set at the root, so it + * holds from the first paint, for the boot skeletons too; + * lib/window-controls-overlay.ts keeps it up to date afterwards. + */ +:root { + --titlebar-inset: env(titlebar-area-height, 0px); + --app-top-inset: max(env(safe-area-inset-top), var(--titlebar-inset)); +} + +/* components/layout/window-title-bar.tsx: what the window is dragged by. */ +.window-title-bar { + app-region: drag; + -webkit-app-region: drag; +} + /* Safe area insets for notched devices (iPhone X+, etc.) */ .safe-area-inset-top { padding-top: env(safe-area-inset-top); diff --git a/app/manifest.ts b/app/manifest.ts index 1a06f3b58..951269292 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -15,7 +15,8 @@ type WebAppProtocolHandler = { url: string; }; -type ExtendedManifest = MetadataRoute.Manifest & { +type ExtendedManifest = Omit & { + display_override?: Array<"window-controls-overlay" | "standalone" | "minimal-ui" | "browser" | "fullscreen">; protocol_handlers?: WebAppProtocolHandler[]; launch_handler?: { client_mode?: "navigate-existing" | "auto" | "focus-existing" | "navigate-new" @@ -90,6 +91,9 @@ export default async function manifest(): Promise { start_url: withBase("/"), scope: withBase("/"), display: "standalone", + // The page draws up to the top of the window, next to the native controls + // (components/layout/window-title-bar.tsx). `display` is the fallback. + display_override: ["window-controls-overlay", "standalone"], orientation: "portrait-primary", theme_color: themeColor, background_color: backgroundColor, diff --git a/components/admin/admin-shell.tsx b/components/admin/admin-shell.tsx index 5343afe10..857ab630b 100644 --- a/components/admin/admin-shell.tsx +++ b/components/admin/admin-shell.tsx @@ -263,7 +263,7 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly, const backHref = location.detail ? adminPath(location.page) : '/admin'; return ( -
+
{showIndex ? ( -
+
{/* The same rail as mail and settings, with Admin as the current app. */}
diff --git a/components/boot-skeleton/admin-skeleton.tsx b/components/boot-skeleton/admin-skeleton.tsx index 5eeeeebaf..16bc29d97 100644 --- a/components/boot-skeleton/admin-skeleton.tsx +++ b/components/boot-skeleton/admin-skeleton.tsx @@ -171,7 +171,7 @@ export function AdminSkeleton({ label, page, backLabel, searchLabel, labels, cla {/* Desktop */}
diff --git a/components/boot-skeleton/pro-skeleton.tsx b/components/boot-skeleton/pro-skeleton.tsx index a5294ee56..3e6eecda0 100644 --- a/components/boot-skeleton/pro-skeleton.tsx +++ b/components/boot-skeleton/pro-skeleton.tsx @@ -109,7 +109,7 @@ export function ProFrameSkeleton({
diff --git a/components/boot-skeleton/shell.tsx b/components/boot-skeleton/shell.tsx index ced5c7b32..15f66019d 100644 --- a/components/boot-skeleton/shell.tsx +++ b/components/boot-skeleton/shell.tsx @@ -294,7 +294,7 @@ export function AppShellSkeleton({
diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 1bccba921..907a6f8bc 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -2153,7 +2153,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = ); return ( -
+
{!isEmbedded && {tShell("skip_to_content")}} {refreshIndicator} @@ -2211,7 +2211,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = "border-e border-border bg-secondary flex flex-col overflow-hidden flex-shrink-0", !isResizing && "transition-[width] duration-300", isNarrow && cn( - "absolute inset-y-0 start-0 z-50 w-72 pt-[env(safe-area-inset-top)]", + "absolute inset-y-0 start-0 z-50 w-72 pt-[var(--app-top-inset)]", "transform transition-transform duration-300 ease-in-out", !narrowSidebarOpen && "-translate-x-full rtl:translate-x-full" ) @@ -2546,7 +2546,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = role="dialog" aria-modal="true" aria-label={editTask ? t("tasks.edit") : t("tasks.create")} - className={isPaneScoped ? "absolute inset-0 z-40 flex flex-col bg-background" : "fixed inset-0 z-50 flex flex-col bg-background"} + className={isPaneScoped ? "absolute inset-0 z-40 flex flex-col bg-background" : "fixed inset-0 z-50 flex flex-col bg-background pt-[var(--titlebar-inset)]"} > (isEdit && !startInEditMode ? "view" : "edit"); diff --git a/components/command-palette/command-palette.tsx b/components/command-palette/command-palette.tsx index dee815fb7..058d4a448 100644 --- a/components/command-palette/command-palette.tsx +++ b/components/command-palette/command-palette.tsx @@ -326,7 +326,7 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS aria-label={t("title")} className={cn( "w-full max-w-xl overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-xl", - "max-sm:h-full max-sm:max-w-none max-sm:rounded-none max-sm:border-0 max-sm:pt-[env(safe-area-inset-top)]", + "max-sm:h-full max-sm:max-w-none max-sm:rounded-none max-sm:border-0 max-sm:pt-[var(--app-top-inset)]", "flex flex-col", )} > diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index 1b371b44a..71e8a7adc 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -1200,7 +1200,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = }; return ( -
+
{!isEmbedded && {tShell("skip_to_content")}} {refreshIndicator} @@ -1256,7 +1256,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = "border-e border-border flex flex-col flex-shrink-0 bg-background", !isSidebarResizing && "transition-[width] duration-300", isNarrow && cn( - "absolute inset-y-0 start-0 z-50 w-72 pt-[env(safe-area-inset-top)]", + "absolute inset-y-0 start-0 z-50 w-72 pt-[var(--app-top-inset)]", "transform transition-[transform,visibility] duration-300 ease-in-out", !narrowSidebarOpen && "invisible -translate-x-full rtl:translate-x-full" ) diff --git a/components/email/__tests__/composer-own-window.test.tsx b/components/email/__tests__/composer-own-window.test.tsx new file mode 100644 index 000000000..8555c06fb --- /dev/null +++ b/components/email/__tests__/composer-own-window.test.tsx @@ -0,0 +1,254 @@ +import React from 'react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextIntlClientProvider } from 'next-intl'; +import { EmailComposer, type ComposerDraftData, type ComposerPopOut } from '../email-composer'; +import { JMAPClient } from '@/lib/jmap/client'; +import { useAuthStore } from '@/stores/auth-store'; +import { useIdentityStore } from '@/stores/identity-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useToastStore } from '@/stores/toast-store'; +import en from '@/locales/en/common.json'; +import { emailHooks } from '@/lib/plugin-hooks'; + +// Moving a draft to its own window, and a composer that is all its window +// shows. Real composer, stores and JMAP draft payloads over a synthetic server. +vi.unmock('next-intl'); +vi.mock('@/components/email/rich-text-editor', () => ({ RichTextEditor: () => null })); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); +vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null })); +vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null })); +vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null })); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ + useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }), + stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }), +})); + +type Props = React.ComponentProps; +type Draft = { subject: string }; + +const original = { auth: useAuthStore.getState(), identity: useIdentityStore.getState(), settings: useSettingsStore.getState() }; +let client: JMAPClient; +let creations: Draft[]; + +beforeEach(async () => { + creations = []; + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ + capabilities: { 'urn:ietf:params:jmap:core': {} }, + accounts: { owner: { name: 'me@example.test', isPersonal: true, accountCapabilities: {} } }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'owner' }, + apiUrl: 'https://synthetic.example.test/api', uploadUrl: 'https://synthetic.example.test/upload/{accountId}', + downloadUrl: 'https://synthetic.example.test/download/{accountId}/{blobId}/{name}', eventSourceUrl: '', + })))); + client = new JMAPClient('https://synthetic.example.test', 'me@example.test', 'synthetic-password'); + await client.connect(); + vi.spyOn(client, 'ping').mockResolvedValue(undefined); + vi.mocked(fetch).mockImplementation(async (_url, init) => { + const { methodCalls } = JSON.parse(String(init?.body)) as { + methodCalls: Array<[string, { create?: Record; ids?: string[]; destroy?: string[] }, string]>; + }; + const methodResponses = []; + for (const [method, args, id] of methodCalls) { + if (method === 'Mailbox/get') methodResponses.push([method, { list: [{ id: 'drafts', role: 'drafts' }] }, id]); + else if (method === 'Email/set' && args.create) { + const created: Record = {}; + for (const [key, draft] of Object.entries(args.create)) { + creations.push(draft); + created[key] = { id: `saved-${creations.length}` }; + } + methodResponses.push([method, { created }, id]); + } else if (method === 'Email/get') methodResponses.push([method, { list: (args.ids ?? []).map((draftId) => ({ id: draftId, attachments: [] })) }, id]); + else if (method === 'Email/set') methodResponses.push([method, { destroyed: args.destroy ?? [] }, id]); + else methodResponses.push([method, {}, id]); + } + return new Response(JSON.stringify({ methodResponses })); + }); + useAuthStore.setState({ client, activeAccountId: 'login', isAuthenticated: true, isLoading: false, + getClientForAccount: (id) => id === 'login' ? client : undefined, + getAllConnectedClients: () => new Map([['login', client]]), + }); + useIdentityStore.setState({ identities: [{ id: 'me', email: 'me@example.test', name: 'Me', mayDelete: false } as never] }); + useSettingsStore.setState({ defaultSendDelay: 0 } as never); + useToastStore.getState().clearToasts(); +}); + +afterEach(() => { + cleanup(); + client.disconnect(); + useAuthStore.setState(original.auth); + useIdentityStore.setState(original.identity); + useSettingsStore.setState(original.settings); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function mount(props: Partial) { + return render( + + + , + ); +} + +const subjectField = () => screen.getByPlaceholderText(en.email_composer.subject_placeholder); + +function target(): ComposerPopOut & { handed: ComposerDraftData[]; cancelled: number } { + const handed: ComposerDraftData[] = []; + const window = { handed, cancelled: 0, handOver: (data: ComposerDraftData) => { handed.push(data); }, cancel: () => { window.cancelled += 1; } }; + return window; +} + +describe('"Open in new window" on a composer', () => { + it('is not there without the host offering it: browsers stay as they are', () => { + mount({}); + expect(screen.queryByTestId('composer-open-in-window')).toBeNull(); + }); + + it('opens the window inside the click, before any save', async () => { + const popOut = vi.fn(() => target()); + mount({ popOut }); + fireEvent.click(screen.getByTestId('composer-open-in-window')); + // Synchronously: a browser only opens a window for a user action. + expect(popOut).toHaveBeenCalledTimes(1); + await waitFor(() => expect(popOut.mock.results[0].value.handed).toHaveLength(1)); + }); + + it('saves the text, hands over the same draft id, and closes here', async () => { + const window = target(); + const onClose = vi.fn(); + const onSaveState = vi.fn(); + mount({ popOut: () => window, onClose, onSaveState }); + fireEvent.change(subjectField(), { target: { value: 'Moves along' } }); + fireEvent.click(screen.getByTestId('composer-open-in-window')); + await waitFor(() => expect(window.handed).toHaveLength(1)); + const data = window.handed[0]; + expect(data.subject).toBe('Moves along'); + expect(creations.map((draft) => draft.subject)).toEqual(['Moves along']); + expect(data.draftId).toBe('saved-1'); + expect(data.unsaved).toBe(false); + expect(onClose).toHaveBeenCalledTimes(1); + // Not left behind in the host's "continue draft" slot. + cleanup(); + expect(onSaveState).not.toHaveBeenCalled(); + }); + + it('keeps a draft that is already saved under its id, without saving again', async () => { + const window = target(); + mount({ + popOut: () => window, + initialData: { to: '', cc: '', bcc: '', subject: 'Old', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', mode: 'compose', draftId: 'existing' }, + }); + fireEvent.click(screen.getByTestId('composer-open-in-window')); + await waitFor(() => expect(window.handed).toHaveLength(1)); + expect(window.handed[0].draftId).toBe('existing'); + expect(creations).toEqual([]); + }); + + it('stays open and says why when the browser blocks the window', async () => { + const onClose = vi.fn(); + mount({ popOut: () => null, onClose }); + fireEvent.change(subjectField(), { target: { value: 'Stays' } }); + fireEvent.click(screen.getByTestId('composer-open-in-window')); + await waitFor(() => expect(useToastStore.getState().toasts.map((toast) => toast.title)).toContain(en.pwaWindows.popup_blocked)); + expect(onClose).not.toHaveBeenCalled(); + expect((subjectField() as HTMLInputElement).value).toBe('Stays'); + }); +}); + +describe('a composer that is all its window shows', () => { + function unload() { + const event = new Event('beforeunload', { cancelable: true }); + window.dispatchEvent(event); + return event; + } + + it('asks before the window closes with unsaved text, and saves meanwhile', async () => { + const onUnloadState = vi.fn(); + mount({ ownWindow: { onUnloadState } }); + fireEvent.change(subjectField(), { target: { value: 'Not saved yet' } }); + const event = unload(); + expect(event.defaultPrevented).toBe(true); + expect(onUnloadState.mock.calls[0][0]).toMatchObject({ subject: 'Not saved yet', unsaved: true }); + await waitFor(() => expect(creations.map((draft) => draft.subject)).toEqual(['Not saved yet'])); + }); + + it('hands unsaved text to another window as it closes, with no prompt and no save of its own', () => { + const rescueDraft = vi.fn(() => true); + mount({ ownWindow: { rescueDraft } }); + fireEvent.change(subjectField(), { target: { value: 'Rescued' } }); + expect(unload().defaultPrevented).toBe(false); + expect(rescueDraft).toHaveBeenCalledTimes(1); + expect(rescueDraft.mock.calls[0]).toEqual([expect.objectContaining({ subject: 'Rescued', draftId: undefined })]); + expect(creations).toEqual([]); + }); + + it('replaces the version it holds when another window saves it', () => { + const rescueDraft = vi.fn(() => true); + mount({ + ownWindow: { rescueDraft }, + initialData: { to: '', cc: '', bcc: '', subject: 'Old', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', mode: 'compose', draftId: 'saved-before' }, + }); + fireEvent.change(subjectField(), { target: { value: 'New' } }); + unload(); + expect(rescueDraft.mock.calls[0]).toEqual([expect.objectContaining({ subject: 'New', draftId: 'saved-before' })]); + }); + + it('asks and saves itself when no other window can take it', async () => { + const rescueDraft = vi.fn(() => false); + mount({ ownWindow: { rescueDraft } }); + fireEvent.change(subjectField(), { target: { value: 'Nobody home' } }); + expect(unload().defaultPrevented).toBe(true); + await waitFor(() => expect(creations.map((draft) => draft.subject)).toEqual(['Nobody home'])); + }); + + it('keeps the draft to itself when a plugin has a say in what is saved', async () => { + const rescueDraft = vi.fn(() => true); + const hook = emailHooks.onBeforeDraftAutoSave.register('test', (draft: unknown) => draft as never); + try { + mount({ ownWindow: { rescueDraft } }); + fireEvent.change(subjectField(), { target: { value: 'Encrypted, say' } }); + expect(unload().defaultPrevented).toBe(true); + expect(rescueDraft).not.toHaveBeenCalled(); + } finally { + hook.dispose(); + } + }); + + it('lets an untouched composer go without a word', () => { + mount({ ownWindow: {} }); + expect(unload().defaultPrevented).toBe(false); + expect(creations).toEqual([]); + }); + + it('does not ask again, or save again, once the autosave has it', async () => { + const onDraftSaved = vi.fn(); + mount({ ownWindow: { onDraftSaved } }); + fireEvent.change(subjectField(), { target: { value: 'Autosaved' } }); + await waitFor(() => expect(creations).toHaveLength(1), { timeout: 4000 }); + await waitFor(() => expect(onDraftSaved).toHaveBeenCalled()); + expect(unload().defaultPrevented).toBe(false); + expect(creations).toHaveLength(1); + }); + + it('keeps today\'s behaviour outside a window of its own: no prompt', () => { + mount({}); + fireEvent.change(subjectField(), { target: { value: 'In the main window' } }); + expect(unload().defaultPrevented).toBe(false); + }); + + it('closes itself after the send, and writes no draft on the way out', async () => { + const onClose = vi.fn(); + const onSend = vi.fn(async () => {}); + mount({ + ownWindow: {}, onSend, onClose, + initialData: { to: 'them@example.test', cc: '', bcc: '', subject: 'Off it goes', body: '

Hello

', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', mode: 'compose', draftId: null }, + }); + fireEvent.click(screen.getAllByTestId('composer-send')[0]); + await waitFor(() => expect(onSend).toHaveBeenCalled(), { timeout: 4000 }); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + const saved = creations.length; + expect(unload().defaultPrevented).toBe(false); + expect(creations).toHaveLength(saved); + }); +}); diff --git a/components/email/__tests__/download-all-attachments.test.tsx b/components/email/__tests__/download-all-attachments.test.tsx index 1eaf7d825..7b82f8d3c 100644 --- a/components/email/__tests__/download-all-attachments.test.tsx +++ b/components/email/__tests__/download-all-attachments.test.tsx @@ -10,6 +10,7 @@ import { useSettingsStore } from '@/stores/settings-store'; import { useToastStore } from '@/stores/toast-store'; import { ToastContainer } from '@/components/ui/toast'; import en from '@/locales/en/common.json'; +import { setDownloadGuard } from '@/lib/download-guard'; // Keep the actual viewer handler, translations, JMAP routing/auth/retries and // JSZip. Stub unrelated viewer hosts, as in the other viewer component tests. @@ -312,3 +313,128 @@ describe('Download all attachments', () => { await p.assertZip(['report.txt', 'data.bin'], [alpha, beta]); }); }); + +describe('Save all attachments to a folder', () => { + /** A folder as the picker hands it out; `failOn` makes that file's write fail. */ + function fakeFolder(failOn?: string) { + const files = new Map(); + return { + files, + name: 'Downloads', + async getFileHandle(name: string, options?: { create?: boolean }) { + if (!files.has(name) && !options?.create) throw Object.assign(new Error('missing'), { name: 'NotFoundError' }); + return { + createWritable: async () => ({ + write: async (data: Blob | Uint8Array) => { + if (name === failOn) throw new Error('Disk full'); + files.set(name, data); + }, + close: async () => {}, + abort: async () => {}, + }), + }; + }, + async removeEntry(name: string) { files.delete(name); }, + }; + } + + function stubPicker(result: () => Promise) { + const picker = vi.fn(result); + (window as unknown as { showDirectoryPicker?: unknown }).showDirectoryPicker = picker; + return picker; + } + + const saveButton = () => screen.getByTestId('attachments-save-to-folder'); + + afterEach(() => { + delete (window as unknown as { showDirectoryPicker?: unknown }).showDirectoryPicker; + setDownloadGuard(null); + }); + + it('is not offered where the browser cannot ask for a folder, and the zip stays', async () => { + await openViewer(); + expect(screen.queryByTestId('attachments-save-to-folder')).toBeNull(); + expect(screen.getAllByTitle('Download all')[0]).toBeVisible(); + }); + + it('writes each attachment to the folder, names the folder, and starts no zip', async () => { + const folder = fakeFolder(); + const picker = stubPicker(async () => folder); + const p = await openViewer(); + p.success(); + fireEvent.click(saveButton()); + expect(picker).toHaveBeenCalledWith(expect.objectContaining({ mode: 'readwrite' })); + await waitFor(() => expect(useToastStore.getState().toasts).toEqual([ + expect.objectContaining({ type: 'success', title: 'Saved 2 attachments to Downloads.' }), + ])); + expect([...folder.files.keys()]).toEqual(['report.txt', 'data.bin']); + p.noDownload(); + }); + + it('asks the device policy before the picker: no save-all when downloads are blocked', async () => { + const picker = stubPicker(async () => fakeFolder()); + const p = await openViewer(); + setDownloadGuard(() => false); + fireEvent.click(saveButton()); + await act(async () => {}); + expect(picker).not.toHaveBeenCalled(); + expect(p.fetchMock).not.toHaveBeenCalled(); + }); + + it('asks again before each file: a policy turning on stops the rest and says how many went', async () => { + const folder = fakeFolder(); + stubPicker(async () => folder); + const p = await openViewer(); + p.success(); + let asked = 0; + // The click, then the first file's two checks. Off from the second file on. + setDownloadGuard(() => ++asked <= 3); + fireEvent.click(saveButton()); + await waitFor(() => expect(useToastStore.getState().toasts).toEqual([ + expect.objectContaining({ type: 'warning', title: 'Saved 1 of 2 attachments to Downloads.' }), + ])); + expect([...folder.files.keys()]).toEqual(['report.txt']); + }); + + it('does nothing when the picker is closed', async () => { + stubPicker(async () => { throw Object.assign(new Error('closed'), { name: 'AbortError' }); }); + const p = await openViewer(); + fireEvent.click(saveButton()); + await act(async () => {}); + expect(p.fetchMock).not.toHaveBeenCalled(); + expect(useToastStore.getState().toasts).toEqual([]); + p.noDownload(); + }); + + it('falls back to the zip when the browser refuses the picker', async () => { + stubPicker(async () => { throw Object.assign(new Error('refused'), { name: 'SecurityError' }); }); + const p = await openViewer(); + p.success(); + fireEvent.click(saveButton()); + await waitFor(() => expect(p.downloads).toHaveLength(1), { timeout: 5000 }); + await p.assertZip(['report.txt', 'data.bin'], [alpha, beta]); + }); + + it('says how many were saved when a write fails midway', async () => { + const folder = fakeFolder('data.bin'); + stubPicker(async () => folder); + const p = await openViewer(); + p.success(); + fireEvent.click(saveButton()); + await waitFor(() => expect(useToastStore.getState().toasts).toEqual([ + expect.objectContaining({ type: 'warning', title: 'Saved 1 of 2 attachments to Downloads.' }), + ])); + expect([...folder.files.keys()]).toEqual(['report.txt']); + }); + + it('says so when nothing could be written', async () => { + const folder = fakeFolder('report.txt'); + stubPicker(async () => folder); + const p = await openViewer(); + p.success(); + fireEvent.click(saveButton()); + await waitFor(() => expect(useToastStore.getState().toasts).toEqual([ + expect.objectContaining({ type: 'error', title: 'Could not save the attachments to Downloads.' }), + ])); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 77163d04b..3bbf8b2f0 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -166,6 +166,7 @@ import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text"; import { fileStorage } from "@/lib/plugin-storage"; import { usePolicyStore } from "@/stores/policy-store"; import { registerComposeFlush } from "@/lib/compose-flush-registry"; +import type { RescuedDraft } from "@/lib/popout/channel"; import { toUnicodeEmail } from "@/lib/idn"; /** @@ -280,6 +281,12 @@ export interface ComposerDraftData { unsaved?: boolean; } +/** The window a draft moves to; see EmailComposerProps.popOut. */ +export interface ComposerPopOut { + handOver(data: ComposerDraftData): void; + cancel(): void; +} + interface EmailComposerProps { /** * Submits the message. Resolves `false` when the host sent nothing (it has @@ -343,6 +350,29 @@ interface EmailComposerProps { */ onDiscardDraft?: (draftId: string, draft?: { accountId?: string; localAccountId?: string }) => void; onSaveState?: (data: ComposerDraftData) => void; + /** + * Offers "Open in new window". Called in the click, since a browser only + * opens a window for a user action: the host opens it and answers with what + * takes the draft, or null when no window opened. The composer then saves, + * hands the draft over under the same draft id, and closes here. + */ + popOut?: () => ComposerPopOut | null; + /** + * This composer is all a window shows (lib/popout). It closes itself once a + * message has gone, and closing the window with unsaved text asks first, so + * the save started on the way out gets to finish. + */ + ownWindow?: { + /** The state as the window unloads, for a reload to start from. */ + onUnloadState?: (data: ComposerDraftData) => void; + /** A save reached the server, for other windows showing the Drafts folder. */ + onDraftSaved?: () => void; + /** + * Hands a draft to another window to save as this one closes: a request + * started now wouldn't survive. False when no window can take it. + */ + rescueDraft?: (draft: RescuedDraft) => boolean; + }; className?: string; initialDraftText?: string; initialData?: ComposerDraftData | null; @@ -489,6 +519,8 @@ export function EmailComposer({ requestCloseRef, onDiscardDraft, onSaveState, + popOut, + ownWindow, className, initialDraftText: initialDraftTextProp, initialData: initialDataProp, @@ -501,6 +533,7 @@ export function EmailComposer({ }: EmailComposerProps) { const t = useTranslations('email_composer'); const tCommon = useTranslations('common'); + const tWindows = useTranslations('pwaWindows'); const tQuote = useTranslations('quote_header'); const tViewer = useTranslations('email_viewer'); const tClassification = useTranslations('classification'); @@ -1823,34 +1856,60 @@ export function EmailComposer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [effectiveSessionKey]); + const rescueRequestRef = useRef<() => RescuedDraft | null>(() => null); + + // The composer as a host needs it to bring it back: after a remount, in + // another window. Read through refs, so a handler set up once stays current. + const snapshotStateRef = useRef<() => ComposerDraftData>(() => { throw new Error('Not mounted'); }); + snapshotStateRef.current = () => ({ + ...stateRef.current, + draftAccountId: draftAccountIdRef.current, + mode, + replyTo, + priority: priorityRef.current, + classification: classificationRef.current.draftChoice, + // Edits the last save didn't cover; a minimised composer with + // none needs no save before an automatic sign-out. + unsaved: changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current, + }); + // Auto-save state on unmount (when user navigates away without explicitly closing) useEffect(() => { return () => { if (explicitCloseRef.current) return; - if (onSaveState && isDirtyRef.current) { - const s = stateRef.current; - onSaveState({ - ...s, - draftAccountId: draftAccountIdRef.current, - mode, - replyTo, - priority: priorityRef.current, - classification: classificationRef.current.draftChoice, - // Edits the last save didn't cover; a minimised composer with - // none needs no save before an automatic sign-out. - unsaved: changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current, - }); - } + if (onSaveState && isDirtyRef.current) onSaveState(snapshotStateRef.current()); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Auto-save draft to server on page close (best-effort) + const ownWindowRef = useRef(ownWindow); + ownWindowRef.current = ownWindow; useEffect(() => { - const handleBeforeUnload = () => { - if (isDirtyRef.current) { - saveDraftRef.current(); + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + const own = ownWindowRef.current; + if (!own) { + if (isDirtyRef.current) { + saveDraftRef.current(); + } + return; } + // Sent, discarded or saved and closed: the window closes with nothing to keep. + if (explicitCloseRef.current || sentRef.current) return; + if (!isDirtyRef.current) return; + const state = snapshotStateRef.current(); + own.onUnloadState?.(state); + if (!state.unsaved) return; + // A request started while a window closes is dropped, so another window + // saves it. A plugin may change or stop what is saved, and only runs here. + if (own.rescueDraft && emailHooks.onBeforeDraftAutoSave.size === 0) { + const draft = rescueRequestRef.current(); + if (!draft || own.rescueDraft(draft)) return; + } + // Nobody can take it. The prompt keeps the window open: "stay" lets the + // save run, and "leave" is the user's own choice. + saveDraftRef.current(); + event.preventDefault(); }; window.addEventListener('beforeunload', handleBeforeUnload); return () => window.removeEventListener('beforeunload', handleBeforeUnload); @@ -2652,28 +2711,17 @@ export function EmailComposer({ }, [previewAttachment, composerClient]); // Auto-save draft functionality - // `auto`: the debounced save while typing. Only that one may be skipped - // by a plugin; saves on close and before send always run, or the text - // would be lost. - const saveDraftOnce = async (auto = false): Promise => { - if (!client || !composerClient) return null; - const versionAtStart = changeVersionRef.current; + const draftRecipients = () => ({ + toAddresses: expandRecipients(withInput(to, toInput)).map(r => formatRecipient(r.name, r.email)), + ccAddresses: expandRecipients(withInput(cc, ccInput)).map(r => formatRecipient(r.name, r.email)), + bccAddresses: expandRecipients(withInput(bcc, bccInput)).map(r => formatRecipient(r.name, r.email)), + }); - const toAddresses = expandRecipients(withInput(to, toInput)).map(r => formatRecipient(r.name, r.email)); - const ccAddresses = expandRecipients(withInput(cc, ccInput)).map(r => formatRecipient(r.name, r.email)); - const bccAddresses = expandRecipients(withInput(bcc, bccInput)).map(r => formatRecipient(r.name, r.email)); - - if (!toAddresses.length && !subject && !(plainTextMode ? body.trim() : htmlToPlainText(body).trim())) { - return null; - } - - try { - await rehomeForeignBlobs(); - } catch (error) { - // The save below then fails with blobNotFound and reports it. - debug.warn('email', 'Failed to copy forwarded parts to the sending account:', error); - } + const draftHasContent = (toAddresses: string[]) => + !!toAddresses.length || !!subject || !!(plainTextMode ? body.trim() : htmlToPlainText(body).trim()); + // What a saved draft holds, as of now. No awaits: a closing window has none to spare. + const draftContent = () => { // Prepare attachments for draft. cid/disposition ride along so inline // parts of a re-opened draft keep matching the body's cid: references. const uploadedAttachments = attachmentsRef.current @@ -2707,6 +2755,81 @@ export function EmailComposer({ : appendPlainTextSignature(body, signatureIdentity, { separator: signatureSeparatorEnabled })) : htmlToPlainText(draftHtmlBody!, imageAltText); + return { uploadedAttachments, draftHtmlBody, draftTextBody }; + }; + + // Get the selected identity or primary identity + // Generate sub-addressed email if tag is set + const draftSender = () => { + const identityFromEmail = currentIdentity?.email + ? subAddressTag + ? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter) + : currentIdentity.email + : undefined; + const fromEmail = (fromOverrideEnabled && fromOverrideEmail.trim()) + ? fromOverrideEmail.trim() + : identityFromEmail; + const fromName = (fromOverrideEnabled && fromOverrideEmail.trim()) + ? (fromOverrideName.trim() || undefined) + : (currentIdentity?.name || undefined); + return { fromEmail, fromName }; + }; + + // The save below, for another window to make as this one closes. Null with nothing to save. + rescueRequestRef.current = () => { + const { toAddresses, ccAddresses, bccAddresses } = draftRecipients(); + if (!draftHasContent(toAddresses)) return null; + const { uploadedAttachments, draftHtmlBody, draftTextBody } = draftContent(); + const { fromEmail, fromName } = draftSender(); + const login = loginFor(currentIdentityParts.localAccountId); + const previousLogin = draftLocalAccountIdRef.current; + // A version saved through another login can't be replaced from this one. + const replaces = !previousLogin || previousLogin === login; + return { + localAccountId: login, + to: toAddresses, + subject, + body: draftTextBody, + cc: ccAddresses, + bcc: bccAddresses, + identityId: currentIdentityRawId, + fromEmail, + draftId: replaces ? draftIdRef.current || undefined : undefined, + attachments: uploadedAttachments, + fromName, + htmlBody: draftHtmlBody, + inReplyTo: replyThreadingHeaders?.inReplyTo, + references: replyThreadingHeaders?.references, + options: { + priority, + previousDraftAccountId: replaces ? draftAccountIdRef.current : undefined, + classification: classificationRef.current.draftChoice, + }, + }; + }; + + // `auto`: the debounced save while typing. Only that one may be skipped + // by a plugin; saves on close and before send always run, or the text + // would be lost. + const saveDraftOnce = async (auto = false): Promise => { + if (!client || !composerClient) return null; + const versionAtStart = changeVersionRef.current; + + const { toAddresses, ccAddresses, bccAddresses } = draftRecipients(); + + if (!draftHasContent(toAddresses)) { + return null; + } + + try { + await rehomeForeignBlobs(); + } catch (error) { + // The save below then fails with blobNotFound and reports it. + debug.warn('email', 'Failed to copy forwarded parts to the sending account:', error); + } + + const { uploadedAttachments, draftHtmlBody, draftTextBody } = draftContent(); + // Create a hash of current data to compare with last saved. Hashing the // composed bodies (not the raw editor body) means a signature that changed // under an unchanged body - switching identity, toggling the separator - @@ -2726,19 +2849,7 @@ export function EmailComposer({ setSaveStatus('saving'); - // Get the selected identity or primary identity - // Generate sub-addressed email if tag is set - const identityFromEmail = currentIdentity?.email - ? subAddressTag - ? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter) - : currentIdentity.email - : undefined; - const fromEmail = (fromOverrideEnabled && fromOverrideEmail.trim()) - ? fromOverrideEmail.trim() - : identityFromEmail; - const fromName = (fromOverrideEnabled && fromOverrideEmail.trim()) - ? (fromOverrideName.trim() || undefined) - : (currentIdentity?.name || undefined); + const { fromEmail, fromName } = draftSender(); try { const previousDraftId = draftIdRef.current; @@ -2893,6 +3004,7 @@ export function EmailComposer({ // Only the save the user asked for is read out: autosave would say it // every minute, and the save before sending is followed by "Sent". if (opts.announce && id && !saveFailedRef.current) announceStatus(t('draft_saved')); + if (id && !saveFailedRef.current) ownWindowRef.current?.onDraftSaved?.(); return id; })(); inflightSaveRef.current = promise; @@ -3990,8 +4102,10 @@ export function EmailComposer({ // Clear ref so unmount effect doesn't re-save stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, draftAccountId: undefined, draftLocalAccountId: undefined, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '', attachments: [], plainTextMode }; // The host closes the composer from onSend or onScheduledSendCreated; - // a send the plugin made itself reaches neither (#1207). - if (sendHandledByPlugin && !effectiveDelayedUntil) cleanClose(); + // a send the plugin made itself reaches neither (#1207). A window of + // its own closes only now: closing it stops whatever is still running, + // such as removing the draft's last version above. + if (ownWindowRef.current || (sendHandledByPlugin && !effectiveDelayedUntil)) cleanClose(); } catch (err) { // Not sent, so the composer stays open and is an unsent draft again. explicitCloseRef.current = false; @@ -4131,6 +4245,64 @@ export function EmailComposer({ } }; + // Moves the draft to its own window. The window opens first, inside the + // click, and waits; this window saves what the server doesn't have yet, + // gives it the draft under the same id, and closes the composer here. + const [isPoppingOut, setIsPoppingOut] = useState(false); + const poppingOutRef = useRef(false); + const handlePopOut = async () => { + if (!popOut || poppingOutRef.current || isSendingRef.current || saveCloseAttemptRef.current) return; + const target = popOut(); + if (!target) { + toast.error(tWindows('popup_blocked')); + return; + } + poppingOutRef.current = true; + setIsPoppingOut(true); + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = null; + } + try { + const uploads = await waitForPendingUploads(() => attachmentsRef.current, () => false); + if (uploads === 'failed') { + target.cancel(); + toast.error(t('save_failed')); + return; + } + if (isDirtyRef.current && (changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current)) { + // A failed save doesn't stop the move: the text goes along, marked unsaved. + await saveDraftRef.current().catch(() => null); + } + const data = snapshotStateRef.current(); + target.handOver({ + ...data, + draftId: draftIdRef.current ?? lastDraftIdRef.current, + draftLocalAccountId: draftLocalAccountIdRef.current, + }); + explicitCloseRef.current = true; + sendCancelledRef.current = true; + emitClose(); + } finally { + poppingOutRef.current = false; + setIsPoppingOut(false); + } + }; + + const popOutButton = (className: string, iconClassName: string) => popOut && ( + + ); + const handleDiscardAndClose = () => { cancelSaveAndClose(); sendCancelledRef.current = true; @@ -4323,6 +4495,7 @@ export function EmailComposer({ )} + {popOutButton("grid h-8 w-8 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground", "h-4 w-4")} )} {rightPanelToggle} + {popOutButton("hidden md:grid h-8 w-8 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground", "w-4 h-4")} {/* Close at the top end (R4). On a phone it moves to the start, as the phone's mail apps have it, with Send at the end. */} void; onBatchForwardAsAttachment?: () => void; + /** Opens the message in its own window (single message). */ + onOpenInWindow?: () => void; /** Save the message as .eml, print it, show its source (single message). */ onExport?: () => void; onPrint?: () => void; @@ -183,6 +185,7 @@ export function EmailContextMenu({ onRescheduleScheduled, copyTargets, onCopyToAccount, + onOpenInWindow, onExport, onPrint, onViewSource, @@ -308,6 +311,7 @@ export function EmailContextMenu({ onRescheduleScheduled, onCancelScheduledForEdit, onCancelScheduledSend, + onOpenInWindow, onExport, onPrint, onViewSource, diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index bebd4f42c..fe25f6dc0 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -27,6 +27,7 @@ import { useEmailStore, resolveEmailBlobContext, ArchiveMailboxNotFoundError, re import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder"; import { useShallow } from "zustand/react/shallow"; import { useAuthStore } from "@/stores/auth-store"; +import { useOpenEmailInWindow } from "@/hooks/use-popouts-available"; import { useSettingsStore } from "@/stores/settings-store"; import { useListRowShape } from "@/hooks/use-list-row-shape"; import { MailListRowsSkeleton, type MailRowShape } from "@/components/boot-skeleton/mail-skeleton"; @@ -177,6 +178,7 @@ export function EmailList({ }: EmailListProps) { const t = useTranslations('email_list'); const tContextMenu = useTranslations('context_menu'); + const openInWindow = useOpenEmailInWindow(); const tSpam = useTranslations('email_viewer.spam'); const tNotifications = useTranslations('notifications'); const tViewer = useTranslations('email_viewer'); @@ -1360,6 +1362,7 @@ export function EmailList({ : [contextMenuEmail!]; await copyToAccount(emails, accountId, mailboxId); }} + onOpenInWindow={openInWindow && contextMenuEmail ? () => openInWindow(contextMenuEmail) : undefined} // Export, print and View source follow the device policy of the row's own account. onExport={contextMenuEmail && !canExport(contextMenuEmail) ? undefined : () => { const target = contextMenuEmail!; diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 9162f0d14..e75a260ca 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -82,6 +82,7 @@ import { MailOpen, Loader2, Printer, + ExternalLink, FileText, File, Shield, @@ -158,6 +159,7 @@ import { requestInternalMailto } from "@/lib/protocol-handlers/mailto"; import { imageBlobUrl, inertBlobType, isFilePreviewable, isMimeTypeSafeForInlinePreview, toInertBlob } from "@/lib/file-preview"; import { openPreviewInNewWindow } from "@/lib/standalone-display"; import { downloadAllowed, saveBlobAs } from "@/lib/download-guard"; +import { canPickFolder, createUniqueNamer, isPickerCancelled, pickFolder, saveFilesToFolder, type FolderHandle } from "@/lib/attachments/save-to-folder"; import { useDevicePolicy } from "@/hooks/use-device-policy"; import { Watermark, useWatermarkText } from "@/components/device/watermark"; import { watermarkForPrint } from "@/lib/watermark"; @@ -285,6 +287,8 @@ interface EmailViewerProps { * minimize button; `isFullscreen` reflects the host's current state. */ onToggleFullscreen?: () => void; + /** Opens this message in its own window; left out where that isn't on offer. */ + onOpenInWindow?: () => void; isFullscreen?: boolean; currentUserEmail?: string; currentUserName?: string; @@ -1318,6 +1322,7 @@ export function EmailViewer({ onRescheduleScheduled, onCompose, onToggleFullscreen, + onOpenInWindow, isFullscreen = false, currentUserEmail, currentUserName, @@ -1329,6 +1334,7 @@ export function EmailViewer({ }: EmailViewerProps) { const t = useTranslations('email_viewer'); const archiveActionEnabled = useArchiveActionEnabled(); + const tWindows = useTranslations('pwaWindows'); const tComposer = useTranslations('email_composer'); const tNotifications = useTranslations('notifications'); const tImport = useTranslations('mail_import'); @@ -3005,10 +3011,24 @@ export function EmailViewer({ saveBlobAs(blob, downloadName, { kind: 'attachment', subject: email }); }, [onDownloadAttachment, email, resolveAttachmentName]); + // An attachment's content for "download all" and "save all": fetched + // through the JMAP client when it is blob-backed, the already decoded bytes + // for S/MIME-decrypted and TNEF-extracted ones. Null when it has none. + const readAttachmentContent = useCallback(async (attachment: EffectiveAttachment, name: string): Promise | null> => { + if (attachment.blobId && blobClient) { + return blobClient.fetchBlob(attachment.blobId, attachment.name || name, attachment.type, blobAccountId); + } + if (attachment.tnefData) return attachment.tnefData as Uint8Array; + if (attachment.decryptedAttachment) { + const bytes = getAttachmentContentBytes(attachment.decryptedAttachment); + if (bytes && bytes.byteLength > 0) return bytes as Uint8Array; + } + return null; + }, [blobClient, blobAccountId]); + // Bundle every attachment of this email into a single .zip and download it. - // Fetches blob-backed attachments through the JMAP client and reuses already - // decoded bytes for S/MIME-decrypted and TNEF-extracted ones. Individual - // failures are skipped so a single bad blob doesn't sink the whole archive. + // Individual failures are skipped so a single bad blob doesn't sink the + // whole archive. const handleDownloadAllAttachments = useCallback(async () => { if (isDownloadingAll || effectiveAttachments.length === 0) return; if (!downloadAllowed('attachment', email)) return; @@ -3016,39 +3036,17 @@ export function EmailViewer({ try { const { default: JSZip } = await import('jszip'); const zip = new JSZip(); - const used = new Set(); - // Zip entries must be unique; suffix collisions with " (n)" before the - // extension so duplicates stay recognisable. - const uniqueName = (raw: string): string => { - const base = raw || 'attachment'; - if (!used.has(base)) { used.add(base); return base; } - const dot = base.lastIndexOf('.'); - const stem = dot > 0 ? base.slice(0, dot) : base; - const ext = dot > 0 ? base.slice(dot) : ''; - let i = 1; - let candidate = `${stem} (${i})${ext}`; - while (used.has(candidate)) { i++; candidate = `${stem} (${i})${ext}`; } - used.add(candidate); - return candidate; - }; + // Zip entries must be unique. + const uniqueName = createUniqueNamer(); let added = 0; for (const attachment of effectiveAttachments) { const entryName = uniqueName(getAttachmentDisplayName(attachment.name, attachment.type)); try { - if (attachment.blobId && blobClient) { - const blob = await blobClient.fetchBlob(attachment.blobId, attachment.name || entryName, attachment.type, blobAccountId); - zip.file(entryName, blob); + const content = await readAttachmentContent(attachment, entryName); + if (content) { + zip.file(entryName, content); added++; - } else if (attachment.tnefData) { - zip.file(entryName, attachment.tnefData); - added++; - } else if (attachment.decryptedAttachment) { - const bytes = getAttachmentContentBytes(attachment.decryptedAttachment); - if (bytes && bytes.byteLength > 0) { - zip.file(entryName, bytes); - added++; - } } } catch { // Skip individual failures; remaining attachments still bundle. @@ -3071,7 +3069,43 @@ export function EmailViewer({ } finally { setIsDownloadingAll(false); } - }, [isDownloadingAll, effectiveAttachments, blobClient, blobAccountId, email, t, tFiles]); + }, [isDownloadingAll, effectiveAttachments, readAttachmentContent, email, t, tFiles]); + + // Save every attachment into a folder the user picks. These writes don't + // pass saveBlobAs, so the device policy is asked here: before the picker, + // and before each file. A browser that refuses the picker gets the zip. + const handleSaveAllToFolder = useCallback(async () => { + if (isDownloadingAll || effectiveAttachments.length === 0) return; + if (!downloadAllowed('attachment', email)) return; + let folder: FolderHandle; + try { + folder = await pickFolder(); + } catch (error) { + if (!isPickerCancelled(error)) void handleDownloadAllAttachments(); + return; + } + setIsDownloadingAll(true); + try { + const result = await saveFilesToFolder( + folder, + effectiveAttachments.map((attachment) => { + const name = getAttachmentDisplayName(attachment.name, attachment.type); + return { name, read: () => readAttachmentContent(attachment, name) }; + }), + () => downloadAllowed('attachment', email), + ); + if (result.saved === result.total) { + toast.success(tWindows('saved_all', { count: result.saved, folder: result.folder })); + } else if (result.saved > 0) { + toast.warning(tWindows('saved_partial', { saved: result.saved, total: result.total, folder: result.folder })); + } else if (result.stopped !== 'policy') { + // The policy said why itself. + toast.error(tWindows('save_failed', { folder: result.folder })); + } + } finally { + setIsDownloadingAll(false); + } + }, [isDownloadingAll, effectiveAttachments, readAttachmentContent, handleDownloadAllAttachments, email, tWindows]); // What the attachment bar and the attachment cards share. const attachmentCardsRef = useRef(null); @@ -3082,6 +3116,7 @@ export function EmailViewer({ onOpen: handleEffectiveAttachmentOpen, onDownload: handleEffectiveAttachmentDownload, onDownloadAll: handleDownloadAllAttachments, + onSaveAllToFolder: canPickFolder() ? () => void handleSaveAllToFolder() : undefined, downloadingAll: isDownloadingAll, downloadsOff, // Save to Files would get around the policy: Files downloads are off too, but the copy would stay. @@ -4479,6 +4514,20 @@ ${printWatermark ? printWatermark.html : ''} {isDark ? : } + {onOpenInWindow && !mvViewer && ( + + )} + {/* Fullscreen toggle - hidden on mobile (already fullscreen there). Never overflows into the More menu: in fullscreen this button is the way back out, so it must stay visible. */} @@ -4630,7 +4679,7 @@ ${printWatermark ? printWatermark.html : ''} inset (same treatment as the preview modals). (#936) */}
{moreMenuSub ? ( )} + {onOpenInWindow && ( + + )} {onToggleFullscreen && ( )} + {attachments.length > 1 && !downloadsOff && onSaveAllToFolder && ( + + )}
+
{!isMobile && (
diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx index 0942d9f7b..04fe72301 100644 --- a/components/files/files-app.tsx +++ b/components/files/files-app.tsx @@ -813,7 +813,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { } return ( -
+
{!isEmbedded && {tShell("skip_to_content")}} {refreshIndicator} diff --git a/components/files/preview-shell.tsx b/components/files/preview-shell.tsx index e1c00de3e..50ada2993 100644 --- a/components/files/preview-shell.tsx +++ b/components/files/preview-shell.tsx @@ -82,7 +82,7 @@ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, c {/* Full-screen under `viewport-fit=cover`: the bar pads itself out of the iOS status bar (#936). The insets are physical, so pl/pr. */}
e.stopPropagation()} >

{name}

diff --git a/components/files/wopi-editor.tsx b/components/files/wopi-editor.tsx index afe6f95fe..bb3f2d5e9 100644 --- a/components/files/wopi-editor.tsx +++ b/components/files/wopi-editor.tsx @@ -124,7 +124,7 @@ export function WopiEditor({ target, accountId, slot, onClose, closeOnBack = fal role="dialog" aria-modal="true" aria-labelledby={titleId} - className="fixed inset-0 z-[100] bg-background flex flex-col" + className="fixed inset-0 z-[100] bg-background flex flex-col pt-[var(--titlebar-inset)]" >

{target.name}

diff --git a/components/layout/window-title-bar.tsx b/components/layout/window-title-bar.tsx new file mode 100644 index 000000000..7f4e306fa --- /dev/null +++ b/components/layout/window-title-bar.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { applyTitlebarInset, watchTitlebarArea, type TitlebarArea } from "@/lib/window-controls-overlay"; + +/** + * The strip above the app in a window without a native title bar: the part + * the user drags the window by, with the window's title in small text. The + * native window controls sit next to it, outside the area it is given. The + * page roots leave this row empty through --titlebar-inset. + * + * Renders nothing, and changes nothing on the page, without an overlay. It + * repeats the title for sighted users only, so it stays out of the + * accessibility tree and out of the tab order. + */ +export function WindowTitleBar() { + const [area, setArea] = useState(null); + const [title, setTitle] = useState(""); + + useEffect(() => watchTitlebarArea((next) => { + applyTitlebarInset(next); + setArea(next); + }), []); + + const visible = area !== null; + useEffect(() => { + if (!visible) return; + const read = () => setTitle(document.title); + read(); + // Each surface sets its own title, and Next puts back on navigation. + const observer = new MutationObserver(read); + observer.observe(document.head, { childList: true, subtree: true, characterData: true }); + return () => observer.disconnect(); + }, [visible]); + + if (!area) return null; + + return ( + <div + aria-hidden="true" + data-window-title-bar="" + className="window-title-bar fixed z-[100002] flex items-center bg-background ps-3 pe-3 text-xs text-muted-foreground select-none" + style={{ top: area.y, left: area.x, width: area.width, height: area.height }} + > + <span className="truncate">{title}</span> + </div> + ); +} diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index c69d7c2c7..403114962 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -146,6 +146,8 @@ import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handler import { PLUGIN_REPLY_EVENT, type PluginReplyRequest } from "@/lib/plugin-sandbox/host-api-compose"; import { onOpenEmail, type OpenEmailHandler, type OpenEmailOutcome } from "@/lib/plugin-sandbox/open-email"; import { mailtoComposerDraft } from "./mailto-draft"; +import { useOpenEmailInWindow, usePopoutsAvailable } from "@/hooks/use-popouts-available"; +import { openComposerWindow } from "@/lib/popout/open"; import { getQuoteBodies } from "@/lib/email-composer-utils"; import { emailHooks } from "@/lib/plugin-hooks"; import { afterRequestDispatch } from "@/lib/after-request-dispatch"; @@ -2162,6 +2164,28 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // another account's message under the same id. const composerSource = usePinnedPerSession(composerSessionId, selectedEmail ? emailWriteSource(selectedEmail, client) : undefined); + const popoutsOn = usePopoutsAvailable(); + const openEmailInWindow = useOpenEmailInWindow(); + const composerPopOut = () => { + const target = openComposerWindow(activeAccountId); + if (!target) return null; + return { + cancel: target.cancel, + handOver: (state: ComposerDraftData) => target.handOver({ + mode: state.mode, + replyTo: state.replyTo, + initialData: state, + sourceEmailId: composerSourceEmail?.id, + sourceEmailSource: composerSource, + composeFromAccountEmail: resolveComposeAccountEmail( + mailboxes, + selectedMailbox, + useAccountStore.getState().getAccountById(viewingAccountId ?? activeAccountId ?? '')?.email, + ), + }), + }; + }; + const handleEmailSend = async (data: { to: string[]; cc: string[]; @@ -4889,6 +4913,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { handleDiscardDraft(draftId, draft); setPendingDraft(null); }} + popOut={popoutsOn && !isEmbedded ? composerPopOut : undefined} /> </ErrorBoundary> ); @@ -4899,7 +4924,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { return ( <DragDropProvider> - <div ref={appRootRef} className={cn("flex flex-col overflow-hidden pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}> + <div ref={appRootRef} className={cn("flex flex-col overflow-hidden pt-[var(--app-top-inset)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}> {/* Rail and folders are many Tab stops: jump past them (WCAG 2.4.1). */} <a href="#message-list" @@ -5012,7 +5037,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { isEmbedded ? (isMobile || isTablet ? cn( - "absolute inset-y-0 start-0 w-72 pt-[env(safe-area-inset-top)]", + "absolute inset-y-0 start-0 w-72 pt-[var(--app-top-inset)]", "transform transition-transform duration-300 ease-in-out", // translate-x is physical, so RTL needs the opposite // sign or the drawer slides off the wrong edge. @@ -5023,7 +5048,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Mobile/Tablet: fixed overlay // start-0 rather than left-0: in RTL the drawer belongs on // the right, matching the hamburger that opens it. - "max-lg:fixed max-lg:inset-y-0 max-lg:start-0 max-lg:w-72 max-lg:pt-[env(safe-area-inset-top)]", + "max-lg:fixed max-lg:inset-y-0 max-lg:start-0 max-lg:w-72 max-lg:pt-[var(--app-top-inset)]", "max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out", !sidebarOpen && "max-lg:-translate-x-full max-lg:rtl:translate-x-full", // Desktop: normal flow @@ -5165,7 +5190,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { {(!isMobile || mobileSearchOpen) && ( <div className={cn( "border-b border-border bg-background", - isMobile && mobileSearchOpen && "fixed inset-0 z-50 overflow-y-auto" + isMobile && mobileSearchOpen && "fixed inset-0 z-50 overflow-y-auto pt-[var(--titlebar-inset)]" )}> {isMobile && mobileSearchOpen && ( <div className="h-14 px-2 flex items-center border-b border-border"> @@ -5527,7 +5552,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { isHorizontalMailLayout ? "min-h-0" : "h-full", // Mobile: full screen overlay when active "max-md:fixed max-md:inset-0 max-md:z-30", - "max-md:h-full max-md:pt-[env(safe-area-inset-top)]", + "max-md:h-full max-md:pt-[var(--app-top-inset)]", isMobile && mobileView !== "viewer" && "max-md:hidden", // Tablet/Desktop: relative. Pane-mobile instead overlays the // pane (absolute within the relative content row above) - the @@ -5712,6 +5737,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } : undefined} mvInlineComposer={mvInlineReply ? <MvComposeInline onPopOut={handleMvPopOut}>{composerNode}</MvComposeInline> : undefined} onToggleFullscreen={!isEmbedded && !isMobile ? () => setFullscreenReading((v) => !v) : undefined} + onOpenInWindow={!isEmbedded && !isMobile && openEmailInWindow && selectedEmail ? () => openEmailInWindow(selectedEmail) : undefined} isFullscreen={fullscreenReading} onShowShortcuts={() => setShowShortcutsModal(true)} onEditDraft={handleEditDraft} diff --git a/components/notifications/notification-center.tsx b/components/notifications/notification-center.tsx index 34204bf66..a3961e7f5 100644 --- a/components/notifications/notification-center.tsx +++ b/components/notifications/notification-center.tsx @@ -158,7 +158,7 @@ export function NotificationSheet({ onClose }: { onClose: () => void }) { role="dialog" aria-modal="true" aria-label={t("title")} - className="fixed inset-0 z-50 flex flex-col bg-background pt-[env(safe-area-inset-top)]" + className="fixed inset-0 z-50 flex flex-col bg-background pt-[var(--app-top-inset)]" > <NotificationPanel onClose={onClose} sheet className="flex-1 min-h-0" /> </div>, diff --git a/components/plugins/plugin-page-app.tsx b/components/plugins/plugin-page-app.tsx index 3d2be1b96..f2fad0c8d 100644 --- a/components/plugins/plugin-page-app.tsx +++ b/components/plugins/plugin-page-app.tsx @@ -133,7 +133,7 @@ export function PluginPageApp({ linkSegments = [] }: PluginPageAppProps) { } return ( - <div className={cn("flex flex-col overflow-hidden pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}> + <div className={cn("flex flex-col overflow-hidden pt-[var(--app-top-inset)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}> <AppTopBannerSlot /> {mvShell && ( <MvTopBar diff --git a/components/popout/__tests__/popout-host.test.tsx b/components/popout/__tests__/popout-host.test.tsx new file mode 100644 index 000000000..d214fe4d6 --- /dev/null +++ b/components/popout/__tests__/popout-host.test.tsx @@ -0,0 +1,233 @@ +import { act, cleanup, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel'; +import { openPopoutChannel, watchMainWindow, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useToastStore } from '@/stores/toast-store'; +import { PopoutHost } from '../popout-host'; + +// The main window's side of pop-outs. The next-intl mock returns the key. + +const DRAFT: RescuedDraft = { + to: ['a@example.test'], subject: 'Closing', body: 'text', cc: [], bcc: [], draftId: 'old', attachments: [], +}; + +const original = { auth: useAuthStore.getState(), email: useEmailStore.getState(), accounts: useAccountStore.getState() }; +let heard: PopoutMessage[]; +let popout: NonNullable<ReturnType<typeof openPopoutChannel>>; +let client: { createDraft: ReturnType<typeof vi.fn>; getAuthHeader: () => string; isOfflineOnly: () => boolean }; +let release: Array<() => void>; + +// Rounds, not one long wait: a message and its answer are two hops, and a busy +// machine can run a single timer before the second hop is even queued. +const settle = async (ms = 10) => { + for (let round = 0; round < 5; round++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, ms)); + }); + } +}; + +/** Web Locks with one holder and a queue, as a browser has. */ +function stubLocks() { + let held = false; + const waiting: Array<() => void> = []; + release = []; + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: { + request(_name: string, options: { signal?: AbortSignal }, callback: () => Promise<unknown>) { + const start = () => { + held = true; + void callback().then(() => { + held = false; + waiting.shift()?.(); + }); + }; + if (held) waiting.push(start); + else start(); + return Promise.resolve(); + }, + }, + }); +} + +beforeEach(() => { + FakeBroadcastChannel.reset(); + vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); + stubLocks(); + heard = []; + popout = openPopoutChannel((message) => heard.push(message))!; + client = { createDraft: vi.fn(async () => 'new'), getAuthHeader: () => 'Bearer main', isOfflineOnly: () => false }; + useAccountStore.setState({ + accounts: [{ id: 'alice', serverUrl: 'https://mail.example.test', username: 'alice', authMode: 'oauth', cookieSlot: 0 } as never], + activeAccountId: 'alice', + }); + useAuthStore.setState({ + isAuthenticated: true, + isDemoMode: false, + activeAccountId: 'alice', + client: client as never, + getAllConnectedClients: () => new Map([['alice', client]]) as never, + getClientForAccount: (id: string) => (id === 'alice' ? client : undefined) as never, + }); + useEmailStore.setState({ refreshCurrentMailbox: vi.fn(async () => {}) as never, fetchMailboxes: vi.fn(async () => {}) as never, pendingUndoSend: null }); + useToastStore.getState().clearToasts(); +}); + +afterEach(() => { + cleanup(); + popout.close(); + useAuthStore.setState(original.auth); + useEmailStore.setState(original.email); + useAccountStore.setState(original.accounts); + vi.unstubAllGlobals(); + Object.defineProperty(navigator, 'locks', { configurable: true, value: undefined }); +}); + +describe('PopoutHost', () => { + it('lends the session to a pop-out that asks', async () => { + render(<PopoutHost />); + popout.post({ type: 'session-request', requestId: 'r1' }); + await settle(); + expect(heard).toContainEqual({ + type: 'session-answer', + requestId: 'r1', + answer: { activeAccountId: 'alice', sessions: [expect.objectContaining({ accountId: 'alice', authHeader: 'Bearer main', authMode: 'oauth' })] }, + }); + }); + + it('says nothing while it has no session to lend, so a pop-out goes on to the cookies', async () => { + useAuthStore.setState({ isAuthenticated: false }); + render(<PopoutHost />); + popout.post({ type: 'session-request', requestId: 'r1' }); + await settle(); + expect(heard.filter((message) => message.type === 'session-answer')).toEqual([]); + }); + + it('renews a token the server refused, once, in this window', async () => { + const refresh = vi.fn(async () => 'next'); + useAuthStore.setState({ refreshAccessToken: refresh as never }); + render(<PopoutHost />); + popout.post({ type: 'session-request', requestId: 'r1', accountId: 'alice', rejectedAuthHeader: 'Bearer main' }); + await settle(); + expect(refresh).toHaveBeenCalledWith({ accountId: 'alice' }); + refresh.mockClear(); + popout.post({ type: 'session-request', requestId: 'r2', accountId: 'alice', rejectedAuthHeader: 'Bearer older' }); + await settle(); + expect(refresh).not.toHaveBeenCalled(); + }); + + it('saves a closing window\'s draft, replacing its version', async () => { + render(<PopoutHost />); + popout.post({ type: 'draft-rescue', draft: DRAFT }); + await settle(); + expect(client.createDraft).toHaveBeenCalledTimes(1); + expect(client.createDraft.mock.calls[0].slice(0, 2)).toEqual([['a@example.test'], 'Closing']); + expect(client.createDraft.mock.calls[0][7]).toBe('old'); + }); + + it('saves it once with two main windows open', async () => { + render(<><PopoutHost /><PopoutHost /></>); + popout.post({ type: 'draft-rescue', draft: DRAFT }); + await settle(); + expect(client.createDraft).toHaveBeenCalledTimes(1); + }); + + it('says so when the save fails: the text is gone otherwise', async () => { + client.createDraft.mockRejectedValueOnce(new Error('nope')); + vi.spyOn(console, 'error').mockImplementation(() => {}); + render(<PopoutHost />); + popout.post({ type: 'draft-rescue', draft: DRAFT }); + await settle(); + expect(useToastStore.getState().toasts.map((toast) => toast.type)).toEqual(['error']); + }); + + it('takes over the send\'s outcome once, and offers Undo here', async () => { + render(<><PopoutHost /><PopoutHost /></>); + const pending = { submissionId: 's', sendAt: '2030-01-01T00:00:00Z', isSmime: false }; + popout.post({ type: 'sent', requestId: 'r1', notice: { accountId: 'alice', pendingUndoSend: pending } }); + await settle(); + expect(heard.filter((message) => message.type === 'sent-ack')).toHaveLength(1); + expect(useEmailStore.getState().pendingUndoSend).toEqual(pending); + expect(useToastStore.getState().toasts).toEqual([]); + + useEmailStore.setState({ pendingUndoSend: null }); + popout.post({ type: 'sent', requestId: 'r2', notice: { accountId: 'alice' } }); + await settle(); + expect(useToastStore.getState().toasts).toHaveLength(1); + }); + + it('refreshes the list and counts when a pop-out changed mail, once for a burst', async () => { + render(<PopoutHost />); + popout.post({ type: 'mail-changed', accountId: 'alice' }); + popout.post({ type: 'mail-changed', accountId: 'alice' }); + await settle(400); + expect(useEmailStore.getState().refreshCurrentMailbox).toHaveBeenCalledTimes(1); + expect(useEmailStore.getState().fetchMailboxes).toHaveBeenCalledTimes(1); + }); + + it('tells pop-outs when its own list changed, since no push reports that to them', async () => { + render(<PopoutHost />); + act(() => useEmailStore.setState({ emails: [{ id: 'x' } as never] })); + await settle(120); + expect(heard).toContainEqual({ type: 'state-change' }); + }); + + it('tells pop-outs when an account went away', async () => { + render(<PopoutHost />); + act(() => useAccountStore.setState({ accounts: [] })); + await settle(); + expect(heard).toContainEqual({ type: 'account-removed', accountId: 'alice' }); + }); +}); + +describe('watchMainWindow', () => { + it('knows a main window is there, and hands it the draft', async () => { + render(<PopoutHost />); + await settle(); + const main = watchMainWindow(); + await settle(); + expect(main.alive()).toBe(true); + expect(main.rescue(DRAFT)).toBe(true); + await settle(); + expect(client.createDraft).toHaveBeenCalledTimes(1); + main.stop(); + }); + + it('knows at once when the last one closed, and takes nothing then', async () => { + render(<PopoutHost />); + await settle(); + const main = watchMainWindow(); + await settle(); + await act(async () => { window.dispatchEvent(new Event('pagehide')); }); + cleanup(); + await settle(); + expect(main.alive()).toBe(false); + expect(main.rescue(DRAFT)).toBe(false); + expect(client.createDraft).not.toHaveBeenCalled(); + main.stop(); + }); + + it('is not fooled by a closing window that still runs long enough to hear the ping', async () => { + render(<PopoutHost />); + await settle(); + const main = watchMainWindow(); + await settle(); + // Still mounted: the page is on its way out, its scripts haven't stopped. + await act(async () => { window.dispatchEvent(new Event('pagehide')); }); + await settle(); + expect(main.alive()).toBe(false); + expect(main.rescue(DRAFT)).toBe(false); + main.stop(); + }); + + it('takes nothing with no main window from the start', async () => { + const main = watchMainWindow(); + await settle(); + expect(main.rescue(DRAFT)).toBe(false); + main.stop(); + }); +}); diff --git a/components/popout/__tests__/popout-message-app.test.tsx b/components/popout/__tests__/popout-message-app.test.tsx new file mode 100644 index 000000000..876d9eced --- /dev/null +++ b/components/popout/__tests__/popout-message-app.test.tsx @@ -0,0 +1,163 @@ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Email } from '@/lib/jmap/types'; +import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel'; +import { openPopoutChannel } from '@/lib/popout/channel'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; + +// The pop-out message around the view it hosts: the view is stubbed, since the +// Pro tab's tests cover it. The next-intl mock returns the key. + +type ViewProps = { + emailId: string; + clientAccountId?: string; + accountId?: string; + liveState?: { keywords?: Record<string, boolean>; mailboxIds?: Record<string, boolean> } | null; + onLoaded?: (email: Email) => void; + onClose: () => void; + onOpenCompose?: (data: { mode: string }) => void; + onMailChanged?: () => void; +}; +let view: ViewProps; + +vi.mock('@/components/pro/pro-email-tab-body', () => ({ + ProEmailView: (props: ViewProps) => { + view = props; + return <div data-testid="message-view" />; + }, +})); +vi.mock('../popout-shell', () => ({ + PopoutShell: ({ children }: { children: React.ReactNode }) => <>{children}</>, + PopoutNotice: ({ title, children }: { title: string; children: React.ReactNode }) => <div><h1>{title}</h1>{children}</div>, +})); +vi.mock('@/hooks/use-document-title', () => ({ useDocumentTitle: vi.fn() })); + +const { PopoutMessageApp } = await import('../popout-message-app'); + +const original = { auth: useAuthStore.getState(), email: useEmailStore.getState() }; +const EMAIL = { id: 'm/1', subject: 'Hello', keywords: { $seen: true }, mailboxIds: { inbox: true } } as unknown as Email; +let found: Array<Partial<Email>> | Error; +let client: { getSomeEmails: ReturnType<typeof vi.fn> }; + +const settle = async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + }); +}; + +beforeEach(() => { + FakeBroadcastChannel.reset(); + vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); + window.history.replaceState(null, '', '/en/popout/message/m%2F1?account=login-b&owner=group'); + found = [EMAIL]; + client = { + getSomeEmails: vi.fn(async () => { + if (found instanceof Error) throw found; + return found; + }), + }; + useAuthStore.setState({ + client: client as never, + activeAccountId: 'login-a', + getClientForAccount: (id: string) => (id === 'login-b' ? client : undefined) as never, + }); + useEmailStore.setState({ + emails: [], + mailboxes: [{ id: 'inbox', name: 'Inbox', role: 'inbox' }, { id: 'archive', name: 'Old mail', role: null }] as never, + fetchMailboxes: vi.fn(async () => {}) as never, + }); +}); + +afterEach(() => { + cleanup(); + useAuthStore.setState(original.auth); + useEmailStore.setState(original.email); + vi.unstubAllGlobals(); + window.history.replaceState(null, '', '/'); +}); + +describe('PopoutMessageApp', () => { + it('opens the message of the address, through its login and account', async () => { + render(<PopoutMessageApp id="rendered-for-another-id" />); + await settle(); + expect(view.emailId).toBe('m/1'); + expect(view.clientAccountId).toBe('login-b'); + expect(view.accountId).toBe('group'); + expect(client.getSomeEmails).toHaveBeenCalledWith(['m/1'], 'group'); + }); + + it('puts the loaded message where delete, move, star and read look for it', async () => { + render(<PopoutMessageApp id="m1" />); + await settle(); + act(() => view.onLoaded?.(EMAIL)); + expect(useEmailStore.getState().emails).toEqual([EMAIL]); + expect(useEmailStore.getState().selectedMailbox).toBe('inbox'); + }); + + it('says so when the message is gone', async () => { + found = []; + render(<PopoutMessageApp id="m1" />); + await screen.findByText('message_gone_title'); + expect(screen.queryByTestId('message-view')).toBeNull(); + }); + + it('keeps the message on screen when the server does not answer', async () => { + found = new TypeError('Failed to fetch'); + render(<PopoutMessageApp id="m1" />); + await settle(); + expect(screen.getByTestId('message-view')).toBeTruthy(); + expect(screen.queryByText('message_gone_title')).toBeNull(); + }); + + it('checks again when another window reports a change, and shows the new flags', async () => { + const main = openPopoutChannel(() => {})!; + render(<PopoutMessageApp id="m1" />); + await settle(); + act(() => view.onLoaded?.(EMAIL)); + found = [{ ...EMAIL, keywords: { $seen: false } }]; + main.post({ type: 'state-change' }); + await waitFor(() => expect(view.liveState?.keywords).toEqual({ $seen: false })); + // And the next toggle starts from them. + expect(useEmailStore.getState().emails[0].keywords).toEqual({ $seen: false }); + main.close(); + }); + + it('checks again when the window gets the focus', async () => { + render(<PopoutMessageApp id="m1" />); + await settle(); + found = []; + act(() => { window.dispatchEvent(new Event('focus')); }); + await screen.findByText('message_gone_title'); + }); + + it('says where a moved message went, and keeps showing it', async () => { + render(<PopoutMessageApp id="m1" />); + await settle(); + expect(document.querySelector('[data-popout-moved]')).toBeNull(); + found = [{ ...EMAIL, mailboxIds: { archive: true } }]; + act(() => { window.dispatchEvent(new Event('focus')); }); + await waitFor(() => expect(document.querySelector('[data-popout-moved]')?.textContent).toContain('message_moved_to')); + expect(screen.getByTestId('message-view')).toBeTruthy(); + }); + + it('tells the main window when it changed something', async () => { + const heard: string[] = []; + const main = openPopoutChannel((message) => heard.push(message.type))!; + render(<PopoutMessageApp id="m1" />); + await settle(); + act(() => view.onMailChanged?.()); + await settle(); + expect(heard).toContain('mail-changed'); + main.close(); + }); + + it('closes the window when the message leaves it', async () => { + const close = vi.spyOn(window, 'close').mockImplementation(() => {}); + render(<PopoutMessageApp id="m1" />); + await settle(); + act(() => view.onClose()); + expect(close).toHaveBeenCalledTimes(1); + close.mockRestore(); + }); +}); diff --git a/components/popout/main-window-only.tsx b/components/popout/main-window-only.tsx new file mode 100644 index 000000000..c518ce99a --- /dev/null +++ b/components/popout/main-window-only.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { isPopoutPath } from "@/lib/popout/window-role"; + +/** + * What belongs to the app as a whole and not to each of its windows: live + * push streams, install and update prompts, the command palette's jumps to + * other surfaces. A pop-out shows one message or one draft, and the main + * window does the rest. + */ +export function MainWindowOnly({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + return isPopoutPath(pathname ?? "") ? null : <>{children}</>; +} diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx new file mode 100644 index 000000000..9436a3b74 --- /dev/null +++ b/components/popout/popout-compose-app.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { EmailComposer, type ComposerDraftData } from "@/components/email/email-composer"; +import { ErrorBoundary, ComposerErrorFallback } from "@/components/error"; +import { useDocumentTitle } from "@/hooks/use-document-title"; +import { useAccountStore } from "@/stores/account-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; +import { toast } from "@/stores/toast-store"; +import { formatRejectedRecipients } from "@/lib/jmap/client"; +import { debug } from "@/lib/debug"; +import { composerSendOptions } from "@/lib/outgoing/composer-send-options"; +import { announceSent } from "@/lib/plugin-after-send"; +import { announceMailChanged, announceSentToMainWindow, watchMainWindow } from "@/lib/popout/channel"; +import { receiveComposeHandoff, rememberComposePayload } from "@/lib/popout/compose-handoff"; +import type { ComposePopoutPayload } from "@/lib/popout/compose-payload"; +import { closePopoutWindow } from "@/lib/popout/window-role"; +import { PopoutShell, PopoutSkeleton } from "./popout-shell"; + +/** A warning with no main window left to show it stays this long before the window goes. */ +const WARNING_READ_MS = 8000; + +type ComposerProps = React.ComponentProps<typeof EmailComposer>; +type SendData = Parameters<NonNullable<ComposerProps["onSend"]>>[0]; + +const EMPTY_PAYLOAD: ComposePopoutPayload = { mode: "compose" }; + +function readLaunch(): { handoffId: string | null; accountId?: string } { + if (typeof window === "undefined") return { handoffId: null }; + const search = new URLSearchParams(window.location.search); + return { handoffId: search.get("handoff"), accountId: search.get("account") ?? undefined }; +} + +/** The composer alone, in its own window: sending, autosave and discard go through the shared stores, as in a Pro tab. */ +function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; handoffId: string | null }) { + const t = useTranslations(); + const client = useAuthStore((s) => s.client); + const [sessionKey] = useState(() => `popout-${handoffId ?? Date.now().toString(36)}`); + + const mode = payload.initialData?.mode ?? payload.mode; + useDocumentTitle( + mode === "reply" ? t("email_composer.reply") + : mode === "replyAll" ? t("email_composer.reply_all") + : mode === "forward" ? t("email_composer.forward") + : t("email_composer.new_message"), + ); + + const handleSend = useCallback(async (data: SendData) => { + if (!client) return false; + const sendClient = data.localAccountId + ? (useAuthStore.getState().getClientForAccount(data.localAccountId) ?? client) + : client; + const emailStore = useEmailStore.getState(); + // A send that fails goes to the composer, which stays open with the text. + const result = await emailStore.sendEmail( + sendClient, + data.to, + data.subject, + data.body, + data.cc, + data.bcc, + data.identityId, + data.fromEmail, + data.draftId, + data.fromName, + data.htmlBody, + data.attachments, + data.inReplyTo, + data.references, + data.delayedUntil, + data.envelopeMailFrom, + composerSendOptions(data), + ); + announceSent(result, data); + + if (payload.sourceEmailId && (mode === "reply" || mode === "replyAll" || mode === "forward")) { + const keyword = mode === "forward" ? "$forwarded" : "$answered"; + try { + await useEmailStore.getState().markEmailKeyword(client, payload.sourceEmailId, keyword, payload.sourceEmailSource ?? undefined); + } catch (error) { + debug.error(`Failed to set ${keyword} keyword:`, error); + } + } + + const accountId = data.localAccountId ?? useAuthStore.getState().activeAccountId; + const pending = useEmailStore.getState().pendingUndoSend; + const rejectedRecipients = result.rejectedRecipients?.length ? formatRejectedRecipients(result.rejectedRecipients) : undefined; + const taken = await announceSentToMainWindow({ + accountId, + scheduled: !!result.scheduled, + queued: !!result.queued, + filingError: !!result.filingError, + rejectedRecipients, + pendingUndoSend: pending ? { ...pending, localAccountId: pending.localAccountId ?? accountId ?? undefined } : null, + }); + // No main window to say so, and this is the only notice these get (#1123). + if (!taken && (result.filingError || rejectedRecipients)) { + if (result.filingError) toast.warning(t("email_composer.send_filing_warning")); + if (rejectedRecipients) toast.warning(t("email_composer.send_some_recipients_rejected"), { message: rejectedRecipients, duration: WARNING_READ_MS }); + await new Promise((resolve) => setTimeout(resolve, WARNING_READ_MS)); + } + }, [client, mode, payload.sourceEmailId, payload.sourceEmailSource, t]); + + const handleDiscardDraft = useCallback(async (draftId: string, draft?: { accountId?: string; localAccountId?: string }) => { + if (!client) return; + // Through the login the draft was saved with, and never another: the id names another message there. + const draftClient = draft?.localAccountId + ? useAuthStore.getState().getClientForAccount(draft.localAccountId) + : client; + if (!draftClient) { + toast.error(t("email_composer.discard_signed_out")); + return; + } + try { + await draftClient.deleteEmail(draftId, draft?.accountId); + } catch (error) { + console.error("Failed to discard draft:", error); + } + }, [client, t]); + + const handleClose = useCallback(() => { + announceMailChanged(useAuthStore.getState().activeAccountId); + closePopoutWindow(); + }, []); + + const [mainWindow] = useState(watchMainWindow); + useEffect(() => () => mainWindow.stop(), [mainWindow]); + + const ownWindow = useMemo(() => ({ + rescueDraft: mainWindow.rescue, + // A reload of this window starts from where the text is now. + onUnloadState: (state: ComposerDraftData) => { + if (handoffId) rememberComposePayload(handoffId, { ...payload, initialData: state, prefill: null }); + }, + onDraftSaved: () => announceMailChanged(useAuthStore.getState().activeAccountId), + }), [handoffId, mainWindow, payload]); + + return ( + <ErrorBoundary fallback={ComposerErrorFallback}> + <EmailComposer + mode={mode} + replyTo={payload.replyTo} + initialDraftText={payload.initialDraftText} + initialData={payload.initialData} + prefill={payload.prefill} + sessionKey={sessionKey} + sourceEmailId={payload.sourceEmailId ?? undefined} + composeFromAccountEmail={payload.composeFromAccountEmail + ?? useAccountStore.getState().getAccountById(useAuthStore.getState().activeAccountId ?? "")?.email} + onSend={handleSend} + onClose={handleClose} + onDiscardDraft={handleDiscardDraft} + ownWindow={ownWindow} + className="min-h-0 flex-1" + /> + </ErrorBoundary> + ); +} + +/** `/popout/compose`: a draft handed over by another window, or a new message. */ +export function PopoutComposeApp() { + const t = useTranslations("pwaWindows"); + const [{ handoffId, accountId }] = useState(readLaunch); + const [payload, setPayload] = useState<ComposePopoutPayload | null>(handoffId ? null : EMPTY_PAYLOAD); + + useEffect(() => { + if (!handoffId) return; + let cancelled = false; + void receiveComposeHandoff(handoffId).then((received) => { + if (cancelled) return; + // Called off or never ready: the composer stayed where it was. + if (received) setPayload(received); + else closePopoutWindow(); + }); + return () => { + cancelled = true; + }; + }, [handoffId]); + + return ( + <PopoutShell kind="compose" accountId={accountId}> + {payload ? <PopoutComposer payload={payload} handoffId={handoffId} /> : <PopoutSkeleton label={t("opening")} />} + </PopoutShell> + ); +} diff --git a/components/popout/popout-host.tsx b/components/popout/popout-host.tsx new file mode 100644 index 000000000..6a45275f5 --- /dev/null +++ b/components/popout/popout-host.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useTranslations } from "next-intl"; +import { useAccountStore } from "@/stores/account-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; +import { toast } from "@/stores/toast-store"; +import { openPopoutChannel, type PopoutChannel, type PopoutSentNotice, type RescuedDraft, type SharedAccountSession } from "@/lib/popout/channel"; +import { changesAnyType, subscribePushStateChanges } from "@/lib/push-events"; + +/** At most this often: a pop-out's autosave reports every few seconds. */ +const REFRESH_THROTTLE_MS = 1500; +/** What the list changing in this window waits for, so a burst tells pop-outs once. */ +const LIST_CHANGE_DEBOUNCE_MS = 400; + +/** Held by one main window at a time, for what must happen once: a draft saved twice is two drafts. */ +export const POPOUT_LEADER_LOCK = "bulwark-popout-leader"; + +/** The sessions this window can lend to a pop-out. Empty until it is signed in, and in the demo. */ +function shareableSessions(): SharedAccountSession[] { + const auth = useAuthStore.getState(); + if (!auth.isAuthenticated || auth.isDemoMode) return []; + const sessions: SharedAccountSession[] = []; + for (const [accountId, client] of auth.getAllConnectedClients()) { + const account = useAccountStore.getState().getAccountById(accountId); + if (!account) continue; + sessions.push({ + accountId, + serverUrl: account.serverUrl, + username: account.username, + authMode: account.authMode, + authHeader: client.getAuthHeader(), + ...(client.isOfflineOnly() ? { offlineOnly: true } : {}), + }); + } + return sessions; +} + +/** + * The main window's side of pop-outs: lends them its session and renews it + * for them, passes on that mail changed, and takes over what a pop-out can't + * say any more once it has closed. Every main window runs one; a pop-out takes + * the first answer. + */ +export function PopoutHost() { + const t = useTranslations(); + const tRef = useRef(t); + tRef.current = t; + + useEffect(() => { + let channel: PopoutChannel | null = null; + let closing = false; + + // The browser gives the lock to the next window when this one goes away. + let leader = false; + const stopLeading = new AbortController(); + void navigator.locks?.request(POPOUT_LEADER_LOCK, { signal: stopLeading.signal }, () => { + leader = true; + return new Promise<void>((resolve) => stopLeading.signal.addEventListener("abort", () => resolve())); + }).catch(() => {}); + + const answerSessionRequest = async (requestId: string, accountId?: string, rejectedAuthHeader?: string) => { + const auth = useAuthStore.getState(); + // The server refused what the pop-out holds. If that is what this window + // holds too, renew it here, where the timers and the sign-out live. + if (accountId && rejectedAuthHeader && auth.getClientForAccount(accountId)?.getAuthHeader() === rejectedAuthHeader) { + await auth.refreshAccessToken({ accountId }); + } + const sessions = shareableSessions().filter((session) => !accountId || session.accountId === accountId); + if (sessions.length === 0) return; + channel?.post({ + type: "session-answer", + requestId, + answer: { activeAccountId: useAuthStore.getState().activeAccountId, sessions }, + }); + }; + + // List and counts, so read state and Drafts follow what a pop-out did. + let refreshTimer: ReturnType<typeof setTimeout> | null = null; + const refreshList = () => { + if (refreshTimer) return; + refreshTimer = setTimeout(() => { + refreshTimer = null; + const client = useAuthStore.getState().client; + if (!client) return; + const emails = useEmailStore.getState(); + void emails.refreshCurrentMailbox(client); + void emails.fetchMailboxes(client); + }, REFRESH_THROTTLE_MS); + }; + + const takeOverSent = (notice: PopoutSentNotice) => { + const translate = tRef.current; + if (notice.filingError) toast.warning(translate("email_composer.send_filing_warning")); + if (notice.rejectedRecipients) { + toast.warning(translate("email_composer.send_some_recipients_rejected"), { message: notice.rejectedRecipients, duration: 15000 }); + } + // Undo lives here now: the window that sent it is closing. + if (notice.pendingUndoSend) useEmailStore.setState({ pendingUndoSend: notice.pendingUndoSend }); + else if (!notice.queued && !notice.scheduled && !notice.filingError && !notice.rejectedRecipients) toast.success(translate("notifications.email_sent")); + refreshList(); + }; + + const saveRescuedDraft = async (draft: RescuedDraft) => { + const auth = useAuthStore.getState(); + const client = draft.localAccountId ? auth.getClientForAccount(draft.localAccountId) : auth.client; + try { + if (!client) throw new Error("No login to save through"); + await client.createDraft( + draft.to, draft.subject, draft.body, draft.cc, draft.bcc, draft.identityId, draft.fromEmail, draft.draftId, + draft.attachments, draft.fromName, draft.htmlBody, draft.inReplyTo, draft.references, draft.options, + ); + refreshList(); + } catch (error) { + console.error("Failed to save a closing window's draft:", error); + toast.error(tRef.current("email_composer.save_failed")); + } + }; + + channel = openPopoutChannel((message) => { + switch (message.type) { + case "session-request": + void answerSessionRequest(message.requestId, message.accountId, message.rejectedAuthHeader); + break; + case "sent": + if (!leader) break; + channel?.post({ type: "sent-ack", requestId: message.requestId }); + takeOverSent(message.notice); + break; + case "draft-rescue": + if (leader) void saveRescuedDraft(message.draft); + break; + case "main-ping": + if (leader && !closing) channel?.post({ type: "main-pong" }); + break; + case "mail-changed": + refreshList(); + break; + } + }); + if (!channel) return; + + const stopPush = subscribePushStateChanges((change) => { + if (changesAnyType(change, ["Email", "Mailbox"])) channel?.post({ type: "state-change" }); + }); + // This window's own changes reach no push stream on the way to a pop-out. + let listTimer: ReturnType<typeof setTimeout> | null = null; + const stopList = useEmailStore.subscribe((state, prev) => { + if (state.emails === prev.emails && state.selectedEmail === prev.selectedEmail) return; + if (listTimer) clearTimeout(listTimer); + listTimer = setTimeout(() => channel?.post({ type: "state-change" }), LIST_CHANGE_DEBOUNCE_MS); + }); + const stopAccounts = useAccountStore.subscribe((state, prev) => { + if (state.accounts === prev.accounts) return; + const ids = new Set(state.accounts.map((account) => account.id)); + for (const account of prev.accounts) { + if (!ids.has(account.id)) channel?.post({ type: "account-removed", accountId: account.id }); + } + }); + // Hearing "gone", a pop-out asks who else is there. This window may still + // run long enough to hear that, and must not answer: a draft handed to it + // now would be lost. + const onPageHide = () => { + closing = true; + if (leader) channel?.post({ type: "main-gone" }); + }; + const onPageShow = () => { + closing = false; + }; + window.addEventListener("pagehide", onPageHide); + window.addEventListener("pageshow", onPageShow); + return () => { + window.removeEventListener("pagehide", onPageHide); + window.removeEventListener("pageshow", onPageShow); + stopLeading.abort(); + stopPush(); + stopList(); + stopAccounts(); + if (refreshTimer) clearTimeout(refreshTimer); + if (listTimer) clearTimeout(listTimer); + channel?.close(); + }; + }, []); + + return null; +} diff --git a/components/popout/popout-message-app.tsx b/components/popout/popout-message-app.tsx new file mode 100644 index 000000000..dae6e3c32 --- /dev/null +++ b/components/popout/popout-message-app.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { ProEmailView } from "@/components/pro/pro-email-tab-body"; +import { useDocumentTitle } from "@/hooks/use-document-title"; +import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; +import { usePolicyStore } from "@/stores/policy-store"; +import { toast } from "@/stores/toast-store"; +import type { Email } from "@/lib/jmap/types"; +import type { ProComposeTabData } from "@/stores/pro-tab-store"; +import { localizeMailboxName } from "@/lib/mailbox-label"; +import { announceMailChanged, openPopoutChannel } from "@/lib/popout/channel"; +import { openComposerWindow } from "@/lib/popout/open"; +import { closePopoutWindow } from "@/lib/popout/window-role"; +import { PopoutNotice, PopoutShell } from "./popout-shell"; + +/** How often an open message checks that it is still there, without a main window to say so. */ +const CHECK_INTERVAL_MS = 60_000; + +/** + * The id from the address bar: a page the service worker kept for a start + * without the network was rendered for another id. + */ +function readLaunch(paramId: string): { emailId: string; accountId?: string; ownerAccountId?: string } { + if (typeof window === "undefined") return { emailId: paramId }; + const segments = window.location.pathname.split("/").filter(Boolean); + const at = segments.lastIndexOf("message"); + let emailId = paramId; + try { + if (at >= 0 && segments[at + 1]) emailId = decodeURIComponent(segments[at + 1]); + } catch { + // A malformed address: the route's own value. + } + const search = new URLSearchParams(window.location.search); + return { emailId, accountId: search.get("account") ?? undefined, ownerAccountId: search.get("owner") ?? undefined }; +} + +type Presence = "present" | "gone"; +type LiveState = Pick<Email, "keywords" | "mailboxIds">; + +/** + * Whether the message still exists, and in which folders. A JMAP move keeps + * the id, so moved mail stays readable here; only its folders change. + */ +function useMessagePresence(emailId: string, ownerAccountId: string | undefined): { presence: Presence; mailboxIds: string[] | null; liveState: LiveState | null } { + const client = useAuthStore((s) => s.client); + const [presence, setPresence] = useState<Presence>("present"); + const [mailboxIds, setMailboxIds] = useState<string[] | null>(null); + const [liveState, setLiveState] = useState<LiveState | null>(null); + const checking = useRef(false); + + useEffect(() => { + if (!client) return; + let cancelled = false; + const check = async () => { + if (checking.current) return; + checking.current = true; + try { + const [found] = await client.getSomeEmails([emailId], ownerAccountId); + if (cancelled) return; + setPresence(found ? "present" : "gone"); + if (found?.mailboxIds) setMailboxIds(Object.keys(found.mailboxIds).sort()); + if (found) { + setLiveState({ keywords: found.keywords, mailboxIds: found.mailboxIds }); + // What the next star or read toggle starts from. + useEmailStore.setState((state) => ({ + emails: state.emails.map((email) => (email.id === emailId ? { ...email, keywords: found.keywords, mailboxIds: found.mailboxIds } : email)), + })); + } + } catch { + // No answer says nothing about the message: it stays on screen. + } finally { + checking.current = false; + } + }; + void check(); + const channel = openPopoutChannel((message) => { + if (message.type === "state-change") void check(); + }); + const onFocus = () => void check(); + window.addEventListener("focus", onFocus); + const timer = window.setInterval(() => { + if (document.visibilityState === "visible") void check(); + }, CHECK_INTERVAL_MS); + return () => { + cancelled = true; + channel?.close(); + window.removeEventListener("focus", onFocus); + window.clearInterval(timer); + }; + }, [client, emailId, ownerAccountId]); + + return { presence, mailboxIds, liveState }; +} + +function PopoutMessage({ emailId, accountId, ownerAccountId }: { emailId: string; accountId?: string; ownerAccountId?: string }) { + const t = useTranslations("pwaWindows"); + const tSidebar = useTranslations("sidebar"); + const login = useAuthStore((s) => accountId ?? s.activeAccountId ?? undefined); + const client = useAuthStore((s) => (login ? s.getClientForAccount(login) : undefined) ?? s.client); + const mailboxes = useEmailStore((s) => s.mailboxes); + const [subject, setSubject] = useState<string | null>(null); + const subjectInTitle = usePolicyStore((s) => s.loaded && s.isFeatureEnabled("tabTitleSubjectEnabled")); + useDocumentTitle(subjectInTitle ? subject : null); + + useEffect(() => { + if (client && mailboxes.length === 0) void useEmailStore.getState().fetchMailboxes(client); + // Once per client: an account without folders shouldn't ask forever. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [client]); + + const { presence, mailboxIds, liveState } = useMessagePresence(emailId, ownerAccountId); + const firstMailboxIds = useRef<string | null>(null); + if (mailboxIds && firstMailboxIds.current === null) firstMailboxIds.current = mailboxIds.join(); + const moved = !!mailboxIds && firstMailboxIds.current !== mailboxIds.join(); + const movedTo = moved ? mailboxes.find((mailbox) => mailboxIds.includes(mailbox.originalId || mailbox.id)) : undefined; + + const handleClose = useCallback(() => { + announceMailChanged(login ?? null); + closePopoutWindow(); + }, [login]); + + const handleMailChanged = useCallback(() => announceMailChanged(login ?? null), [login]); + + const handleLoaded = useCallback((email: Email) => { + setSubject(email.subject || null); + // Delete, move, star and read look the message up in the list and do + // nothing when it isn't there, and this window has no list of its own. + useEmailStore.setState({ emails: [email], selectedMailbox: Object.keys(email.mailboxIds ?? {})[0] ?? "" }); + }, []); + + // Reply and forward open a composer window of their own. + const openCompose = useCallback((data: Omit<ProComposeTabData, "sessionId">) => { + const opened = openComposerWindow(login); + if (!opened) { + toast.error(t("popup_blocked")); + return; + } + opened.handOver({ + mode: data.mode, + replyTo: data.replyTo, + initialDraftText: data.initialDraftText, + initialData: data.initialData, + prefill: data.prefill, + sourceEmailId: data.sourceEmailId, + sourceEmailSource: data.sourceEmailSource, + }); + }, [login, t]); + + if (presence === "gone") { + return ( + <PopoutNotice title={t("message_gone_title")} body={t("message_gone_body")}> + <Button onClick={handleClose}>{t("close_window")}</Button> + </PopoutNotice> + ); + } + + return ( + <> + {moved && ( + <div role="status" data-popout-moved="" className="shrink-0 border-b border-border bg-muted px-4 py-2 text-sm text-foreground"> + {movedTo + ? t("message_moved_to", { folder: localizeMailboxName(movedTo.role, movedTo.name, (key) => tSidebar(`mailboxes.${key}`)) }) + : t("message_moved")} + </div> + )} + <ProEmailView + emailId={emailId} + client={client ?? undefined} + clientAccountId={login} + accountId={ownerAccountId} + onLoaded={handleLoaded} + onClose={handleClose} + onOpenCompose={openCompose} + liveState={liveState} + onMailChanged={handleMailChanged} + className="min-h-0 w-full flex-1" + /> + </> + ); +} + +/** `/popout/message/<id>`: one message in its own window, with no rail, sidebar or list. */ +export function PopoutMessageApp({ id }: { id: string }) { + const [launch] = useState(() => readLaunch(id)); + return ( + <PopoutShell kind="message" accountId={launch.accountId}> + <PopoutMessage emailId={launch.emailId} accountId={launch.accountId} ownerAccountId={launch.ownerAccountId} /> + </PopoutShell> + ); +} diff --git a/components/popout/popout-shell.tsx b/components/popout/popout-shell.tsx new file mode 100644 index 000000000..9ab8f3937 --- /dev/null +++ b/components/popout/popout-shell.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { useConfig } from "@/hooks/use-config"; +import { useAuthStore, SHARED_SESSION_ENDED_EVENT } from "@/stores/auth-store"; +import { toast } from "@/stores/toast-store"; +import { withBasePath } from "@/lib/browser-navigation"; +import { flushComposers } from "@/lib/compose-flush-registry"; +import { openPopoutChannel } from "@/lib/popout/channel"; +import { closePopoutWindow } from "@/lib/popout/window-role"; + +/** How long a closing pop-out waits for its composer to save, when the account went away elsewhere. */ +const CLOSE_FLUSH_MS = 3000; + +/** The session gate and page frame of a pop-out window. Takes the session from the main window (lib/popout). */ +export function PopoutShell({ kind, accountId, children }: { + kind: "message" | "compose"; + /** The login to open; left out, the main window's active one. */ + accountId?: string; + children: React.ReactNode; +}) { + const t = useTranslations("pwaWindows"); + // Fetches the organisation's policy, which device policies and send rules read. + useConfig(); + const [status, setStatus] = useState<"loading" | "ready" | "no-session">("loading"); + + useEffect(() => { + let cancelled = false; + void useAuthStore.getState().startSharedSession({ accountId }).then((result) => { + if (!cancelled) setStatus(result); + }); + return () => { + cancelled = true; + }; + }, [accountId]); + + // The account was signed out in the main window: save what can be saved, and go. + useEffect(() => { + const channel = openPopoutChannel((message) => { + if (message.type !== "account-removed" || message.accountId !== useAuthStore.getState().activeAccountId) return; + void flushComposers(CLOSE_FLUSH_MS).finally(closePopoutWindow); + }); + return () => channel?.close(); + }, []); + + // Stays on screen: the text in a composer is still worth copying. + useEffect(() => { + const onEnded = () => toast.error(t("session_ended_title"), t("session_ended_body")); + window.addEventListener(SHARED_SESSION_ENDED_EVENT, onEnded); + return () => window.removeEventListener(SHARED_SESSION_ENDED_EVENT, onEnded); + }, [t]); + + return ( + <div data-popout-window={kind} data-popout-status={status} className="flex h-dvh flex-col overflow-hidden bg-background pt-[var(--app-top-inset)]"> + {status === "ready" ? children : status === "loading" ? ( + <PopoutSkeleton label={t("opening")} /> + ) : ( + <PopoutNotice title={t("session_ended_title")} body={t("session_ended_body")}> + <Button onClick={() => window.location.replace(withBasePath("/"))}>{t("open_app")}</Button> + <Button variant="ghost" onClick={closePopoutWindow}>{t("close_window")}</Button> + </PopoutNotice> + )} + </div> + ); +} + +export function PopoutSkeleton({ label }: { label: string }) { + return ( + <div role="status" aria-label={label} className="flex flex-1 flex-col gap-4 p-6" data-popout-skeleton=""> + <div className="h-6 w-2/3 rounded bg-muted" /> + <div className="h-4 w-1/3 rounded bg-muted" /> + <div className="mt-4 h-4 w-full rounded bg-muted" /> + <div className="h-4 w-5/6 rounded bg-muted" /> + <div className="h-4 w-4/6 rounded bg-muted" /> + </div> + ); +} + +export function PopoutNotice({ title, body, children }: { title: string; body: string; children?: React.ReactNode }) { + return ( + <div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center" data-popout-notice=""> + <h1 className="text-lg font-semibold text-foreground">{title}</h1> + <p className="max-w-md text-sm text-muted-foreground">{body}</p> + {children && <div className="mt-2 flex items-center gap-2">{children}</div>} + </div> + ); +} diff --git a/components/popout/window-sync-host.tsx b/components/popout/window-sync-host.tsx new file mode 100644 index 000000000..f1aca1300 --- /dev/null +++ b/components/popout/window-sync-host.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { useEffect } from "react"; +import { startWindowSync } from "@/lib/desktop/window-sync"; + +/** See lib/desktop/window-sync.ts. */ +export function WindowSyncHost() { + useEffect(() => startWindowSync(), []); + return null; +} diff --git a/components/pro/pro-compose-tab-body.tsx b/components/pro/pro-compose-tab-body.tsx index a49e0f2df..3094fa0ff 100644 --- a/components/pro/pro-compose-tab-body.tsx +++ b/components/pro/pro-compose-tab-body.tsx @@ -15,6 +15,8 @@ import { threadKeyFor } from "@/lib/thread-utils"; import { formatRejectedRecipients } from "@/lib/jmap/client"; import { announceSent } from "@/lib/plugin-after-send"; import { composerSendOptions } from "@/lib/outgoing/composer-send-options"; +import { usePopoutsAvailable } from "@/hooks/use-popouts-available"; +import { openComposerWindow } from "@/lib/popout/open"; interface ProComposeTabBodyProps { tabId: string; @@ -212,6 +214,8 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const popoutsOn = usePopoutsAvailable(); + // Mirrors the standard shell (see the same prop in `components/mail/mail-app.tsx`). // Without it `mode === 'compose'` resolves `findComposeIdentityId(identities, // undefined)` -> null and every new message in the Pro shell defaults to the @@ -234,6 +238,22 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { ); }); + const popOut = useCallback(() => { + const target = openComposerWindow(useAuthStore.getState().activeAccountId); + if (!target) return null; + return { + cancel: target.cancel, + handOver: (state: ComposerDraftData) => target.handOver({ + mode: state.mode, + replyTo: state.replyTo, + initialData: state, + sourceEmailId: data.sourceEmailId, + sourceEmailSource: data.sourceEmailSource, + composeFromAccountEmail, + }), + }; + }, [composeFromAccountEmail, data.sourceEmailId, data.sourceEmailSource]); + return ( <div className="flex h-full w-full flex-col bg-background"> <ErrorBoundary fallback={ComposerErrorFallback}> @@ -253,6 +273,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { requestCloseRef={requestCloseRef} onDiscardDraft={handleDiscardDraft} onSaveState={handleSaveState} + popOut={popoutsOn ? popOut : undefined} className="flex-1" /> </ErrorBoundary> diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 6040ba126..224951911 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -31,6 +31,7 @@ import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { saveQuickReplyDraft } from '@/lib/outgoing/quick-reply-envelope'; import { canDownload } from '@/lib/device-policy'; +import { useOpenEmailInWindow } from '@/hooks/use-popouts-available'; import { isFilePreviewable } from '@/lib/file-preview'; import dynamic from 'next/dynamic'; @@ -65,6 +66,15 @@ interface ProEmailViewProps { accountId?: string; /** Fires once the full message is loaded (tab retitling, sidebar highlight). */ onLoaded?: (email: Email) => void; + /** + * Opens a reply, forward or draft somewhere else than a Pro tab: a pop-out + * window (components/popout) has no tabs. + */ + onOpenCompose?: (data: Omit<ProComposeTabData, 'sessionId'>) => void; + /** Flags and folders as the server has them now, when the host tracks them (a pop-out). */ + liveState?: Pick<Email, 'keywords' | 'mailboxIds'> | null; + /** This view changed the message's flags, for a host other windows should hear from. */ + onMailChanged?: () => void; /** * The message left this view: deleted, archived, moved, or reopened in the * composer. The host decides what that means (close the tab / clear the @@ -120,7 +130,7 @@ function buildReplyContext(email: Email): ProReplyContext { * Hosted by `ProEmailTabBody` (a dedicated email tab) and by the folder tab's * reading pane. */ -export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onClose, className }: ProEmailViewProps) { +export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onOpenCompose, liveState, onMailChanged, onClose, className }: ProEmailViewProps) { const t = useTranslations(); const tNotifications = useTranslations('notifications'); const tRemove = useTranslations('email_list.confirm_remove'); @@ -175,8 +185,12 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, onOpened?: () => void, ) => { const open = (prefill: ComposePrefill | null) => { - composerSessionIdRef.current += 1; - openComposeTabNow({ ...data, sessionId: composerSessionIdRef.current, prefill }); + if (onOpenCompose) { + onOpenCompose({ ...data, prefill }); + } else { + composerSessionIdRef.current += 1; + openComposeTabNow({ ...data, sessionId: composerSessionIdRef.current, prefill }); + } onOpened?.(); }; if (emailHooks.onBeforeCompose.size === 0) { @@ -191,7 +205,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, })).then((prefill) => { if (prefill) open(Object.keys(prefill).length > 0 ? prefill : null); }); - }, [identities, openComposeTabNow, t]); + }, [identities, onOpenCompose, openComposeTabNow, t]); // Held in a ref so a host passing an inline callback doesn't refetch the // message on every render. @@ -242,6 +256,13 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, }; }, [client, clientOverride, clientAccountId, emailId, accountId, fetchEmailContent]); + useEffect(() => { + if (!liveState) return; + setEmail((prev) => (prev && prev.id === emailId + ? { ...prev, keywords: liveState.keywords ?? prev.keywords, mailboxIds: liveState.mailboxIds ?? prev.mailboxIds } + : prev)); + }, [liveState, emailId]); + const currentMailboxRole = useMemo(() => { if (!email) return undefined; const mb = email.mailboxIds @@ -359,6 +380,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, if (!client || !email) return; try { if (!(await toggleStar(client, email.id, emailKeyFor(email)))) return; + onMailChanged?.(); // Reflect locally - the viewer re-reads from email-store's selectedEmail // shape only for the mail tab; here we update our local copy too. setEmail((prev) => prev ? { @@ -372,12 +394,13 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, console.error('Toggle star failed:', err); toast.error(tNotifications('error_updating')); } - }, [client, email, toggleStar, tNotifications]); + }, [client, email, toggleStar, tNotifications, onMailChanged]); const handleMarkAsRead = useCallback(async (emailIdToMark: string, read: boolean, opts?: { automatic?: boolean }) => { if (!client) return; try { if (!(await markAsRead(client, emailIdToMark, read))) return; + onMailChanged?.(); setEmail((prev) => prev && prev.id === emailIdToMark ? { ...prev, keywords: { ...prev.keywords, $seen: read }, @@ -387,7 +410,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, // A refused mark-as-read on opening needs no notice (see EmailViewer). if (!opts?.automatic) toast.error(tNotifications('error_updating')); } - }, [client, markAsRead, tNotifications]); + }, [client, markAsRead, tNotifications, onMailChanged]); const handleSetTag = useCallback((emailIdToTag: string, tagId: string | null) => { if (!email || email.id !== emailIdToTag) return; @@ -448,6 +471,8 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, return { blob, contentType: preview.type || blob.type }; }, [preview]); + const openInWindow = useOpenEmailInWindow(); + const handleQuickReply = useCallback(async (body: string) => { handleReply(body); }, [handleReply]); @@ -525,6 +550,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, onMarkAsRead={handleMarkAsRead} onSetTag={handleSetTag} onDownloadAttachment={handleDownloadAttachment} + onOpenInWindow={openInWindow && email ? () => openInWindow(email) : undefined} onQuickReply={handleQuickReply} onSaveQuickReplyDraft={handleSaveQuickReplyDraft} onEditDraft={handleEditDraft} diff --git a/components/pro/pro-interface-redirect.tsx b/components/pro/pro-interface-redirect.tsx index fe7ea4fc2..fee73577d 100644 --- a/components/pro/pro-interface-redirect.tsx +++ b/components/pro/pro-interface-redirect.tsx @@ -76,7 +76,7 @@ export function ProInterfaceRedirect() { if (!takingOver) return null; return ( - <div className="fixed inset-0 z-50" data-pro-takeover=""> + <div className="fixed inset-0 z-50 pt-[var(--titlebar-inset)]" data-pro-takeover=""> <ProSkeleton label={label} /> </div> ); diff --git a/components/protocol/protocol-launch-handler-provider.tsx b/components/protocol/protocol-launch-handler-provider.tsx index 885203711..dbf35ed56 100644 --- a/components/protocol/protocol-launch-handler-provider.tsx +++ b/components/protocol/protocol-launch-handler-provider.tsx @@ -5,6 +5,7 @@ import type { ReactNode } from "react"; import { useTranslations } from "next-intl"; import { usePathname, useRouter } from "@/i18n/navigation"; import { useConfig } from "@/hooks/use-config"; +import { isPopoutPath } from "@/lib/popout/window-role"; import { getPathPrefix } from "@/lib/browser-navigation"; import { productName } from "@/lib/product-name"; import { parseMailto } from "@/lib/protocol-handlers/mailto"; @@ -75,7 +76,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle const pathname = usePathname(); useEffect(() => { - if (pathname.startsWith("/protocol/")) return; + if (pathname.startsWith("/protocol/") || isPopoutPath(pathname)) return; return listenForMailtoRequests((pending) => { savePendingMailto(pending); @@ -90,7 +91,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle }, [pathname, router, t, appName]); useEffect(() => { - if (typeof window === "undefined" || !window.launchQueue) return; + if (typeof window === "undefined" || !window.launchQueue || isPopoutPath(pathname)) return; window.launchQueue.setConsumer((launchParams) => { if (!launchParams.targetURL) return; diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index 93a658443..cea0b8fec 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -910,7 +910,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = if (!isDesktop) { if (mobileShowContent) { return ( - <div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}> + <div className={cn("flex flex-col bg-background pt-[var(--app-top-inset)]", isEmbedded ? "h-full" : "h-dvh")}> <AppTopBannerSlot /> <div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0"> <IconButton label={t('back_to_settings')} size="icon-lg" onClick={() => window.history.back()}> @@ -939,7 +939,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = } return ( - <div className={cn("flex flex-col bg-background pt-[env(safe-area-inset-top)]", isEmbedded ? "h-full" : "h-dvh")}> + <div className={cn("flex flex-col bg-background pt-[var(--app-top-inset)]", isEmbedded ? "h-full" : "h-dvh")}> <AppTopBannerSlot /> <div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0"> <IconButton label={t('back_to_mail')} size="icon-lg" onClick={() => router.push('/')}> @@ -1057,7 +1057,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = </AppMain> ); return ( - <div className={cn("flex flex-col pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}> + <div className={cn("flex flex-col pt-[var(--app-top-inset)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}> {!isEmbedded && <SkipLink>{tShell("skip_to_content")}</SkipLink>} <AppTopBannerSlot /> {mvShell && ( diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index 4512d1c74..88fba7e0c 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -155,8 +155,8 @@ export function Dialog({ return createPortal( <div className={cn( - "fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150", - size === "full-on-phone" && "max-sm:p-0" + "fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 pt-[calc(1rem+var(--titlebar-inset))] animate-in fade-in duration-150", + size === "full-on-phone" && "max-sm:p-0 max-sm:pt-[var(--titlebar-inset)]" )} onMouseDown={(e) => { pressedOnBackdrop.current = e.target === e.currentTarget; diff --git a/hooks/use-calendar-alerts.ts b/hooks/use-calendar-alerts.ts index 7915de752..8c65c12ee 100644 --- a/hooks/use-calendar-alerts.ts +++ b/hooks/use-calendar-alerts.ts @@ -3,6 +3,7 @@ import { useEffect, useRef, useCallback } from 'react'; import { useTranslations, useLocale } from 'next-intl'; import { parseISO } from 'date-fns'; +import { isPopoutWindow } from '@/lib/popout/window-role'; import { useAuthStore } from '@/stores/auth-store'; import { useCalendarStore } from '@/stores/calendar-store'; import { useSettingsStore } from '@/stores/settings-store'; @@ -72,7 +73,9 @@ function remind(view: CalendarAlertView, show: () => void): void { } export function useCalendarAlerts() { - const { isAuthenticated, client } = useAuthStore(); + const { isAuthenticated: signedIn, client } = useAuthStore(); + // The main window rings; a pop-out would ring the same alert again. + const isAuthenticated = signedIn && !isPopoutWindow(); const { events, calendars, supportsCalendar } = useCalendarStore(); const { calendarNotificationsEnabled, calendarNotificationSound, enableCalendarTasks, notificationSoundChoice } = useSettingsStore(); const { tasks: storeTasks } = useTaskStore(); diff --git a/hooks/use-popouts-available.ts b/hooks/use-popouts-available.ts new file mode 100644 index 000000000..7628846a0 --- /dev/null +++ b/hooks/use-popouts-available.ts @@ -0,0 +1,53 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { useAuthStore } from "@/stores/auth-store"; +import { resolveEmailBlobContext } from "@/stores/email-store"; +import { toast } from "@/stores/toast-store"; +import type { Email } from "@/lib/jmap/types"; +import { openMessageWindow } from "@/lib/popout/open"; +import { popoutsAvailable } from "@/lib/popout/support"; +import { isPopoutWindow } from "@/lib/popout/window-role"; + +/** + * Whether to offer "Open in new window" here. False on the server and in the + * first render, so the markup hydrates the same; false in the demo, whose + * client has no session to share, and inside a pop-out (for a message: it is + * already in its own window). + */ +export function usePopoutsAvailable(): boolean { + const isDemoMode = useAuthStore((s) => s.isDemoMode); + const [available, setAvailable] = useState(false); + useEffect(() => { + const check = () => setAvailable(popoutsAvailable() && !isPopoutWindow()); + check(); + // Installing the running tab turns it into a window without a reload. + if (typeof window.matchMedia !== "function") return; + const media = window.matchMedia("(display-mode: standalone)"); + media.addEventListener?.("change", check); + return () => media.removeEventListener?.("change", check); + }, []); + return available && !isDemoMode; +} + +/** + * Opens a message in its own window, through the login and the account it was + * read in. Null when that isn't on offer here. Says so when the browser + * blocks the window; nothing else changes then. + */ +export function useOpenEmailInWindow(): ((email: Email) => void) | null { + const available = usePopoutsAvailable(); + const t = useTranslations("pwaWindows"); + const open = useCallback((email: Email) => { + const auth = useAuthStore.getState(); + const source = resolveEmailBlobContext(email, auth.client); + const opened = openMessageWindow({ + emailId: email.id, + localAccountId: source.clientAccountId ?? auth.activeAccountId, + ownerAccountId: source.accountId, + }); + if (!opened) toast.error(t("popup_blocked")); + }, [t]); + return available ? open : null; +} diff --git a/integration/tests/23-do-pwa-windows.spec.ts b/integration/tests/23-do-pwa-windows.spec.ts new file mode 100644 index 000000000..7619949fe --- /dev/null +++ b/integration/tests/23-do-pwa-windows.spec.ts @@ -0,0 +1,576 @@ +import { test, expect, type Browser, type BrowserContext, type Page } from '@playwright/test'; +import { ENT, adminContext, contextOptions, deliver, jmapAs, requireEntEnv, shot, withPolicy, ENT_PASSWORD } from './helpers/ent'; +import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; + +/** + * Windows of an installed app (design B, step 2) through the real UI on a + * VM 106 test Stalwart with the ent-* accounts: + * - the title bar next to the native window controls; + * - "Save all to a folder", with a stubbed showDirectoryPicker; + * - pop-out message and composer windows: session, draft handover, send, + * close with unsaved text, a message deleted or moved, state across windows. + * + * Headless Chromium has no installed-app mode, so the display mode and the + * overlay API are stubbed by an init script (which reaches popups too). + * window.open, the second window and everything behind it are real. What this + * can't show: a real install opening a popup inside the app rather than in + * the browser. A person has to check that once in Chrome and Edge. + * + * Rig: + * BUILD=1 bash start-prod.sh <worktree> <port> <jmap-url> pw-1.0 + * bash run-spec.sh <worktree> <port> <jmap-url> 1.0 integration/tests/23-do-pwa-windows.spec.ts + */ + +test.setTimeout(180_000); + +const RUN = Date.now().toString(36); +const STANDALONE = `(() => { + const real = window.matchMedia.bind(window); + window.matchMedia = (query) => query.includes('display-mode: standalone') + ? { matches: true, media: query, onchange: null, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, dispatchEvent() { return false; } } + : real(query); +})();`; +const OVERLAY = `(() => { + const overlay = new EventTarget(); + overlay.visible = true; + overlay.getTitlebarAreaRect = () => ({ x: 0, y: 0, width: window.innerWidth - 140, height: 40 }); + Object.defineProperty(navigator, 'windowControlsOverlay', { value: overlay }); +})();`; +const BRIDGE = `window.bulwarkDesktop = { windows: {} };`; + +async function appPage(browser: Browser, ...initScripts: string[]): Promise<Page> { + const context = await browser.newContext(contextOptions()); + for (const script of initScripts) await context.addInitScript(script); + const page = await context.newPage(); + await neutralizeDevOverlay(page); + await page.goto('/'); + await page.fill('#username', ENT.alice); + await page.fill('#password', ENT_PASSWORD); + await page.click('button[type="submit"]'); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 }); + return page; +} + +/** A toast: the text is also in the live region for screen readers. */ +function toast(page: Page, text: string | RegExp) { + return page.getByRole('region', { name: 'Notifications' }).getByText(text).first(); +} + +function row(page: Page, subject: string) { + return page.locator('[data-testid="email-list-item"]', { hasText: subject }).first(); +} + +async function openInMain(page: Page, subject: string): Promise<void> { + await expect(row(page, subject)).toBeVisible({ timeout: 60_000 }); + await row(page, subject).click(); +} + +async function deliverToAlice(subject: string): Promise<string> { + await deliver({ from: ENT.bob, to: [ENT.alice], subject, text: `Body of ${subject}` }); + const alice = await jmapAs(ENT.alice); + const found = await alice.waitForEmail(subject, { timeoutMs: 30_000 }); + return found.id; +} + +/** A message in Alice's inbox with two attachments of the same name and one other. */ +async function messageWithAttachments(subject: string): Promise<void> { + const alice = await jmapAs(ENT.alice); + const inbox = await alice.mailboxByRole('inbox'); + const res = await alice.request([['Email/set', { + accountId: alice.accountId, + create: { m: { + mailboxIds: { [inbox!.id]: true }, + from: [{ email: ENT.bob }], + to: [{ email: ENT.alice }], + subject, + bodyValues: { + t: { value: 'See the files.' }, + a: { value: 'first copy' }, + b: { value: 'second copy' }, + c: { value: 'notes' }, + }, + bodyStructure: { + type: 'multipart/mixed', + subParts: [ + { partId: 't', type: 'text/plain' }, + { partId: 'a', type: 'text/plain', name: 'same.txt', disposition: 'attachment' }, + { partId: 'b', type: 'text/plain', name: 'same.txt', disposition: 'attachment' }, + { partId: 'c', type: 'text/plain', name: 'notes.txt', disposition: 'attachment' }, + ], + }, + } }, + }, '0']]); + if (!res.methodResponses[0][1].created?.m) throw new Error(JSON.stringify(res.methodResponses[0][1])); +} + +/** Stubs the picker on the page, with a folder that records what it is given. */ +async function stubDirectoryPicker(page: Page, opts: { failOn?: string; cancel?: boolean } = {}): Promise<void> { + await page.evaluate(({ failOn, cancel }) => { + const files = new Map<string, string>(); + (window as unknown as { __folderFiles: Map<string, string> }).__folderFiles = files; + (window as unknown as { __pickerCalls: number }).__pickerCalls = 0; + (window as unknown as { showDirectoryPicker: unknown }).showDirectoryPicker = async () => { + (window as unknown as { __pickerCalls: number }).__pickerCalls += 1; + if (cancel) throw new DOMException('closed', 'AbortError'); + return { + name: 'Saved mail', + async getFileHandle(name: string, options?: { create?: boolean }) { + if (!files.has(name) && !options?.create) throw new DOMException('missing', 'NotFoundError'); + return { + createWritable: async () => ({ + write: async (data: Blob) => { + if (name === failOn) throw new DOMException('Disk full', 'QuotaExceededError'); + files.set(name, await data.text()); + }, + close: async () => {}, + abort: async () => {}, + }), + }; + }, + async removeEntry(name: string) { files.delete(name); }, + }; + }; + }, opts); +} + +const folderFiles = (page: Page) => page.evaluate(() => [...(window as unknown as { __folderFiles: Map<string, string> }).__folderFiles.entries()]); + +function watchAuthCalls(context: BrowserContext): string[] { + const calls: string[] = []; + context.on('request', (request) => { + const url = new URL(request.url()); + if (request.method() !== 'PUT') return; + if (url.pathname === '/api/auth/token' || url.pathname === '/api/auth/session') { + calls.push(`${request.frame().page().url()} ${url.pathname}${url.search}`); + } + }); + return calls; +} + +async function openPopout(page: Page, trigger: () => Promise<void>): Promise<Page> { + const [popup] = await Promise.all([page.context().waitForEvent('page'), trigger()]); + await popup.waitForLoadState('domcontentloaded'); + return popup; +} + +async function popOutMessage(page: Page, subject: string): Promise<Page> { + await openInMain(page, subject); + const button = page.locator('[data-testid="viewer-open-in-window"]').first(); + await expect(button).toBeVisible({ timeout: 30_000 }); + const popup = await openPopout(page, () => button.click()); + await expect(popup.locator('[data-popout-window="message"][data-popout-status="ready"]')).toBeVisible({ timeout: 60_000 }); + return popup; +} + +async function draftsCalled(subject: string): Promise<Array<{ id: string; subject: string }>> { + const alice = await jmapAs(ENT.alice); + const drafts = await alice.mailboxByRole('drafts'); + const res = await alice.request([ + ['Email/query', { accountId: alice.accountId, filter: { inMailbox: drafts!.id, subject } }, '0'], + ['Email/get', { accountId: alice.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject'] }, '1'], + ]); + return res.methodResponses[1][1].list; +} + +test.beforeAll(() => requireEntEnv()); + +test.describe('the manifest and the title bar', () => { + test('the manifest asks for the overlay and falls back to standalone', async ({ request, baseURL }) => { + const res = await request.get(`${baseURL ?? process.env.IT_WEBMAIL_URL}/manifest.webmanifest`); + const manifest = await res.json(); + expect(manifest.display).toBe('standalone'); + expect(manifest.display_override).toEqual(['window-controls-overlay', 'standalone']); + }); + + test('without an overlay nothing changes', async ({ browser }) => { + const page = await appPage(browser); + await expect(page.locator('[data-window-title-bar]')).toHaveCount(0); + const padding = await page.evaluate(() => { + const root = document.querySelector('[class*="--app-top-inset"]') as HTMLElement; + return { padding: getComputedStyle(root).paddingTop, top: root.getBoundingClientRect().top }; + }); + expect(padding).toEqual({ padding: '0px', top: 0 }); + await page.context().close(); + }); + + test('with one, the app starts below a drag strip that leaves the controls free', async ({ browser }) => { + const page = await appPage(browser, OVERLAY); + const strip = page.locator('[data-window-title-bar]'); + await expect(strip).toBeVisible(); + const box = await strip.boundingBox(); + expect(box).toMatchObject({ x: 0, y: 0, height: 40 }); + expect(box!.width).toBe(1440 - 140); + const region = await strip.evaluate((el) => { + const style = getComputedStyle(el) as CSSStyleDeclaration & { appRegion?: string; webkitAppRegion?: string }; + return style.appRegion ?? style.webkitAppRegion; + }); + expect(region).toBe('drag'); + // The window title, in small text. + await expect(strip).toContainText(/\S/); + const rail = await page.evaluate(() => { + const root = document.querySelector('[class*="--app-top-inset"]') as HTMLElement; + return { padding: getComputedStyle(root).paddingTop }; + }); + expect(rail.padding).toBe('40px'); + await shot(page, 'pwa-windows-title-bar'); + // A full-page dialog keeps clear of it too. + await page.context().close(); + }); +}); + +test.describe('save all attachments to a folder', () => { + const SUBJECT = `Files ${RUN}`; + + test.beforeAll(() => messageWithAttachments(SUBJECT)); + + test('writes every file, numbers the same name, and says how many', async ({ browser }) => { + const page = await appPage(browser); + await stubDirectoryPicker(page); + await openInMain(page, SUBJECT); + await page.locator('[data-testid="attachments-save-to-folder"]').first().click(); + await expect(toast(page, 'Saved 3 attachments to Saved mail.')).toBeVisible({ timeout: 30_000 }); + const files = new Map(await folderFiles(page)); + expect([...files.keys()].sort()).toEqual(['notes.txt', 'same (1).txt', 'same.txt']); + expect(files.get('notes.txt')).toContain('notes'); + expect([files.get('same.txt'), files.get('same (1).txt')].map((text) => text!.trim()).sort()).toEqual(['first copy', 'second copy']); + await page.context().close(); + }); + + test('a write failing midway says how many were saved', async ({ browser }) => { + const page = await appPage(browser); + await stubDirectoryPicker(page, { failOn: 'notes.txt' }); + await openInMain(page, SUBJECT); + await page.locator('[data-testid="attachments-save-to-folder"]').first().click(); + await expect(toast(page, 'Saved 2 of 3 attachments to Saved mail.')).toBeVisible({ timeout: 30_000 }); + await page.context().close(); + }); + + test('closing the picker does nothing', async ({ browser }) => { + const page = await appPage(browser); + await stubDirectoryPicker(page, { cancel: true }); + await openInMain(page, SUBJECT); + await page.locator('[data-testid="attachments-save-to-folder"]').first().click(); + await expect.poll(() => page.evaluate(() => (window as unknown as { __pickerCalls: number }).__pickerCalls)).toBe(1); + await expect(toast(page, /Saved \d/)).toHaveCount(0); + await page.context().close(); + }); + + test('is gone when the organisation blocks downloads', async ({ browser }) => { + const admin = await adminContext(browser); + const page = await appPage(browser); + await stubDirectoryPicker(page); + await withPolicy(admin, { organisation: { devices: { noDownloads: true } } }, async () => { + await page.reload(); + await stubDirectoryPicker(page); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 }); + await openInMain(page, SUBJECT); + await expect(page.getByTestId('attachments-downloads-off').first()).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="attachments-save-to-folder"]')).toHaveCount(0); + }); + await admin.close(); + await page.context().close(); + }); +}); + +test.describe('pop-out windows', () => { + test('paints from the server HTML: its own frame, no spinner, no rail, the theme set before the first paint', async ({ browser }) => { + const context = await browser.newContext(contextOptions()); + for (const path of ['/en/popout/compose', '/en/popout/message/x1']) { + const res = await context.request.get(path); + expect(res.status(), path).toBe(200); + const html = await res.text(); + expect(html, path).toContain('data-popout-skeleton'); + expect(html, path).toContain('data-popout-status="loading"'); + expect(html, path).not.toContain('animate-spin'); + expect(html, path).not.toContain('data-sk-rail'); + expect(html, path).not.toContain('data-boot-skeleton='); + expect(html.indexOf('theme-storage'), path).toBeLessThan(html.indexOf('data-popout-window')); + } + await context.close(); + }); + + test('a browser tab offers none of it', async ({ browser }) => { + const subject = `Tab ${RUN}`; + await deliverToAlice(subject); + const page = await appPage(browser); + await openInMain(page, subject); + await expect(page.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="viewer-open-in-window"]')).toHaveCount(0); + await page.keyboard.press('c'); + await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible' }); + await expect(page.locator('[data-testid="composer-open-in-window"]')).toHaveCount(0); + await page.context().close(); + }); + + test('the bridge counts too: display-mode is `browser` in Electron', async ({ browser }) => { + const subject = `Bridge ${RUN}`; + await deliverToAlice(subject); + const page = await appPage(browser, BRIDGE); + await openInMain(page, subject); + await expect(page.locator('[data-testid="viewer-open-in-window"]').first()).toBeVisible({ timeout: 30_000 }); + await page.context().close(); + }); + + test('a message opens alone, on the main window\'s session, with no rail and no token renewal', async ({ browser }) => { + const subject = `Popout ${RUN}`; + await deliverToAlice(subject); + const page = await appPage(browser, STANDALONE); + const calls = watchAuthCalls(page.context()); + const popup = await popOutMessage(page, subject); + await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 }); + await expect(popup.locator('nav')).toHaveCount(0); + expect(popup.url()).toContain('/popout/message/'); + await expect(popup).toHaveTitle(new RegExp(subject)); + expect(calls.filter((call) => call.includes('/popout/'))).toEqual([]); + await shot(popup, 'pwa-windows-popout-message'); + await page.context().close(); + }); + + test('opening the same message again focuses its window', async ({ browser }) => { + const subject = `Twice ${RUN}`; + await deliverToAlice(subject); + const page = await appPage(browser, STANDALONE); + const popup = await popOutMessage(page, subject); + const opened: Page[] = []; + page.context().on('page', (p) => opened.push(p)); + const url = popup.url(); + let reloaded = false; + popup.on('framenavigated', () => { reloaded = true; }); + await page.locator('[data-testid="viewer-open-in-window"]').first().click(); + await popup.waitForTimeout(1000); + expect(opened).toEqual([]); + expect(reloaded).toBe(false); + expect(popup.url()).toBe(url); + await page.context().close(); + }); + + test('a session with nothing to restore still works, and survives in the list', async ({ browser }) => { + const subject = `Forgotten ${RUN}`; + await deliverToAlice(subject); + const page = await appPage(browser, STANDALONE); + await page.evaluate(() => { + const registry = JSON.parse(localStorage.getItem('account-registry')!); + for (const account of registry.state.accounts) account.rememberMe = false; + localStorage.setItem('account-registry', JSON.stringify(registry)); + }); + await page.context().clearCookies({ name: 'jmap_session' }); + const popup = await popOutMessage(page, subject); + await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 }); + const accounts = await page.evaluate(() => JSON.parse(localStorage.getItem('account-registry')!).state.accounts.length); + expect(accounts).toBeGreaterThan(0); + await expect(accountSwitcher(page)).toBeVisible(); + await page.context().close(); + }); + + test('a reload with the main window gone restores from the cookies, without evicting', async ({ browser }) => { + const subject = `Alone ${RUN}`; + await deliverToAlice(subject); + const page = await appPage(browser, STANDALONE); + const popup = await popOutMessage(page, subject); + const before = await popup.evaluate(() => localStorage.getItem('account-registry')); + await page.close(); + await popup.reload(); + await expect(popup.locator('[data-popout-window="message"][data-popout-status="ready"]')).toBeVisible({ timeout: 60_000 }); + await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 }); + expect(await popup.evaluate(() => localStorage.getItem('account-registry'))).toBe(before); + await popup.context().close(); + }); + + test('a message deleted or moved while open says so', async ({ browser }) => { + const subject = `Gone ${RUN}`; + const emailId = await deliverToAlice(subject); + const page = await appPage(browser, STANDALONE); + const popup = await popOutMessage(page, subject); + await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 }); + + const alice = await jmapAs(ENT.alice); + const archive = await alice.mailboxByRole('archive'); + if (archive) { + await alice.moveEmail(emailId, archive.id); + await popup.evaluate(() => window.dispatchEvent(new Event('focus'))); + await expect(popup.locator('[data-popout-moved]')).toContainText('moved to', { timeout: 30_000 }); + // A move keeps the id: the message stays readable. + await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible(); + } + + await alice.request([['Email/set', { accountId: alice.accountId, destroy: [emailId] }, '0']]); + await popup.evaluate(() => window.dispatchEvent(new Event('focus'))); + await expect(popup.getByText('This message is gone')).toBeVisible({ timeout: 30_000 }); + await page.context().close(); + }); + + test('deleting from the pop-out closes it', async ({ browser }) => { + const subject = `Delete ${RUN}`; + await deliverToAlice(subject); + const page = await appPage(browser, STANDALONE); + const popup = await popOutMessage(page, subject); + await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 }); + const closed = popup.waitForEvent('close'); + await popup.getByRole('button', { name: /^Delete$/ }).first().click(); + await closed; + // The main window's list follows. + await expect(row(page, subject)).toHaveCount(0, { timeout: 30_000 }); + const alice = await jmapAs(ENT.alice); + const trash = await alice.mailboxByRole('trash'); + await expect.poll(async () => Object.keys((await alice.findEmailBySubject(subject))?.mailboxIds ?? {})).toEqual([trash!.id]); + }); + + test('state follows across windows without a reload', async ({ browser }) => { + const subject = `Sync ${RUN}`; + await deliverToAlice(subject); + const page = await appPage(browser, STANDALONE); + const popup = await popOutMessage(page, subject); + await expect(popup.getByText(`Body of ${subject}`).first()).toBeVisible({ timeout: 60_000 }); + let navigated = 0; + popup.on('framenavigated', () => { navigated += 1; }); + + // Theme. + await page.evaluate(() => { + const stored = JSON.parse(localStorage.getItem('theme-storage') ?? '{"state":{"theme":"light"},"version":1}'); + stored.state.theme = 'dark'; + localStorage.setItem('theme-storage', JSON.stringify(stored)); + }); + await expect(popup.locator('html')).toHaveClass(/dark/, { timeout: 10_000 }); + + // Language. + await page.evaluate(() => localStorage.setItem('locale-storage', JSON.stringify({ state: { locale: 'de' }, version: 0 }))); + await expect(popup.locator('html')).toHaveAttribute('lang', 'de', { timeout: 15_000 }); + await page.evaluate(() => localStorage.setItem('locale-storage', JSON.stringify({ state: { locale: 'en' }, version: 0 }))); + await expect(popup.locator('html')).toHaveAttribute('lang', 'en', { timeout: 15_000 }); + + // Read state: from the pop-out to the main window's list. The main window + // looks at another message: the one it has open it would mark read again. + await expect(row(page, subject)).toHaveAttribute('data-unread', 'false', { timeout: 30_000 }); + const other = `Sync other ${RUN}`; + await deliverToAlice(other); + await openInMain(page, other); + await popup.getByTitle('Mark as unread').first().click(); + await expect(row(page, subject)).toHaveAttribute('data-unread', 'true', { timeout: 30_000 }); + + // And from the main window to the pop-out. + await row(page, subject).click(); + await expect(row(page, subject)).toHaveAttribute('data-unread', 'false', { timeout: 30_000 }); + await expect(popup.getByTitle('Mark as unread').first()).toBeVisible({ timeout: 30_000 }); + expect(navigated).toBe(0); + await page.context().close(); + }); +}); + +test.describe('a composer in its own window', () => { + async function popOutComposer(page: Page, subject: string): Promise<Page> { + await page.keyboard.press('c'); + await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15_000 }); + const input = page.locator('[data-testid="composer-to"] input').first(); + await input.click(); + await input.fill(ENT.bob); + await input.press('Enter'); + await page.locator('[data-testid="composer-subject"]').first().fill(subject); + await page.locator('[data-testid="email-composer"] [contenteditable="true"]').first().fill(`Body of ${subject}`); + const popup = await openPopout(page, () => page.locator('[data-testid="composer-open-in-window"]').first().click()); + await expect(popup.locator('[data-popout-window="compose"][data-popout-status="ready"]')).toBeVisible({ timeout: 60_000 }); + await expect(popup.locator('[data-testid="composer-subject"]').first()).toHaveValue(subject, { timeout: 30_000 }); + return popup; + } + + test('the draft moves over under one draft id, and the window closes after sending', async ({ browser }) => { + const subject = `Moved ${RUN}`; + const page = await appPage(browser, STANDALONE); + const popup = await popOutComposer(page, subject); + + // Gone from the main window, with no "continue draft" left behind. + await expect(page.locator('[data-testid="email-composer"]')).toHaveCount(0); + await expect(popup.locator('[data-testid="composer-to"]')).toContainText(ENT.bob); + await expect(popup.locator('[data-testid="email-composer"] [contenteditable="true"]').first()).toContainText(`Body of ${subject}`); + await expect(popup.locator('nav')).toHaveCount(0); + await expect.poll(async () => (await draftsCalled(subject)).length, { timeout: 15_000 }).toBe(1); + const [first] = await draftsCalled(subject); + await shot(popup, 'pwa-windows-popout-composer'); + + // Further edits replace that draft, they don't start another. + await popup.locator('[data-testid="composer-subject"]').first().fill(`${subject} edited`); + await expect.poll(async () => (await draftsCalled(`${subject} edited`)).length, { timeout: 15_000 }).toBe(1); + await expect.poll(async () => (await draftsCalled(subject)).map((draft) => draft.id).includes(first.id), { timeout: 15_000 }).toBe(false); + + const closed = popup.waitForEvent('close'); + await popup.locator('[data-testid="composer-send"]').first().click(); + await closed; + + const bob = await jmapAs(ENT.bob); + await bob.waitForEmail(`${subject} edited`, { timeoutMs: 60_000 }); + await expect.poll(async () => (await draftsCalled(`${subject} edited`)).length, { timeout: 20_000 }).toBe(0); + // The main window says it went, since the pop-out can't any more. + await expect(toast(page, 'Email sent')).toBeVisible({ timeout: 30_000 }); + await page.context().close(); + }); + + test('closing the window with unsaved text saves the draft', async ({ browser }) => { + const subject = `Unsaved ${RUN}`; + const page = await appPage(browser, STANDALONE); + const popup = await popOutComposer(page, subject); + await expect.poll(async () => (await draftsCalled(subject)).length, { timeout: 15_000 }).toBe(1); + const typed = `${subject} typed`; + await popup.locator('[data-testid="composer-subject"]').first().fill(typed); + // Inside the 2 s of the autosave, so only the close can save it. No prompt: the main window takes it. + let prompted = false; + popup.on('dialog', (dialog) => { prompted = true; void dialog.accept(); }); + const closed = popup.waitForEvent('close'); + await popup.close({ runBeforeUnload: true }); + await closed; + expect(prompted).toBe(false); + await expect.poll(async () => (await draftsCalled(typed)).length, { timeout: 20_000 }).toBe(1); + // It replaced the version the window started from. + await expect.poll(async () => (await draftsCalled(subject)).filter((draft) => draft.subject === subject).length, { timeout: 20_000 }).toBe(0); + await page.context().close(); + }); + + test('with no main window left it asks, and staying lets the save finish', async ({ browser }) => { + const subject = `Asked ${RUN}`; + const page = await appPage(browser, STANDALONE); + const popup = await popOutComposer(page, subject); + await expect.poll(async () => (await draftsCalled(subject)).length, { timeout: 15_000 }).toBe(1); + await page.close(); + const typed = `${subject} typed`; + await popup.locator('[data-testid="composer-subject"]').first().fill(typed); + const asked = new Promise<void>((resolve) => { + popup.once('dialog', (dialog) => { + expect(dialog.type()).toBe('beforeunload'); + void dialog.dismiss(); + resolve(); + }); + }); + await popup.close({ runBeforeUnload: true }); + await asked; + expect(popup.isClosed()).toBe(false); + await expect.poll(async () => (await draftsCalled(typed)).length, { timeout: 20_000 }).toBe(1); + }); + + test('a blocked window leaves the composer where it is', async ({ browser }) => { + const subject = `Blocked ${RUN}`; + const page = await appPage(browser, STANDALONE); + await page.evaluate(() => { window.open = () => null; }); + await page.keyboard.press('c'); + await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15_000 }); + await page.locator('[data-testid="composer-subject"]').first().fill(subject); + await page.locator('[data-testid="composer-open-in-window"]').first().click(); + await expect(toast(page, /blocked the new window/)).toBeVisible(); + await expect(page.locator('[data-testid="composer-subject"]').first()).toHaveValue(subject); + await page.context().close(); + }); +}); + +test.describe('the desktop app\'s behaviour', () => { + test('hangs on the bridge: a browser gets none of it', async ({ browser }) => { + const plain = await appPage(browser); + expect(await plain.locator('html').getAttribute('data-desktop')).toBeNull(); + const button = plain.locator('button').first(); + expect(await button.evaluate((el) => getComputedStyle(el).userSelect)).not.toBe('none'); + await plain.context().close(); + + const app = await appPage(browser, BRIDGE); + expect(await app.locator('html').getAttribute('data-desktop')).toBe(''); + const chrome = app.locator('button').first(); + expect(await chrome.evaluate((el) => getComputedStyle(el).userSelect)).toBe('none'); + expect(await app.evaluate(() => getComputedStyle(document.documentElement).overscrollBehaviorY)).toBe('none'); + await app.context().close(); + }); +}); diff --git a/lib/__tests__/desktop-window-sync.test.ts b/lib/__tests__/desktop-window-sync.test.ts new file mode 100644 index 000000000..afe4640cb --- /dev/null +++ b/lib/__tests__/desktop-window-sync.test.ts @@ -0,0 +1,145 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { desktopBootScript } from '@/lib/desktop/boot'; +import { startWindowSync } from '@/lib/desktop/window-sync'; +import { useLocaleStore } from '@/stores/locale-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useThemeStore } from '@/stores/theme-store'; + +/** What another window of this browser writing the store looks like here. */ +function writeFromAnotherWindow(key: string, state: Record<string, unknown>) { + const current = JSON.parse(localStorage.getItem(key) ?? '{"state":{},"version":0}'); + const value = JSON.stringify({ ...current, state: { ...current.state, ...state } }); + localStorage.setItem(key, value); + window.dispatchEvent(new StorageEvent('storage', { key, newValue: value })); +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('state across windows', () => { + let stop: () => void = () => {}; + + beforeEach(() => { + useThemeStore.setState({ theme: 'light' }); + useLocaleStore.setState({ locale: 'en' }); + }); + + afterEach(() => { + stop(); + stop = () => {}; + delete window.bulwarkDesktop; + window.history.replaceState(null, '', '/'); + localStorage.clear(); + document.documentElement.classList.remove('dark', 'light'); + }); + + function asPopout() { + window.history.replaceState(null, '', '/en/popout/compose'); + stop = startWindowSync(); + } + + it('follows a theme change without a reload, down to the page', async () => { + asPopout(); + writeFromAnotherWindow('theme-storage', { theme: 'dark' }); + await settle(); + expect(useThemeStore.getState().theme).toBe('dark'); + expect(document.documentElement.classList.contains('dark')).toBe(true); + }); + + it('follows a theme pack change too, which rehydrating alone does not put on the page', async () => { + asPopout(); + const pack = useThemeStore.getState().installedThemes.find((theme) => theme.css); + expect(pack).toBeTruthy(); + writeFromAnotherWindow('theme-storage', { theme: 'light', activeThemeId: pack!.id }); + await settle(); + expect(document.querySelector('style[id*="theme"]')?.textContent).toContain(pack!.css); + }); + + it('follows a language change without a reload', async () => { + asPopout(); + writeFromAnotherWindow('locale-storage', { locale: 'de' }); + await settle(); + expect(useLocaleStore.getState().locale).toBe('de'); + }); + + it('follows a settings change without a reload', async () => { + asPopout(); + const before = useSettingsStore.getState().plainTextMode; + writeFromAnotherWindow('settings-storage', { plainTextMode: !before }); + await settle(); + expect(useSettingsStore.getState().plainTextMode).toBe(!before); + }); + + it('also runs in the desktop app\'s main window', async () => { + window.bulwarkDesktop = { windows: {} }; + stop = startWindowSync(); + writeFromAnotherWindow('locale-storage', { locale: 'fr' }); + await settle(); + expect(useLocaleStore.getState().locale).toBe('fr'); + }); + + it('leaves a browser tab as it was', async () => { + stop = startWindowSync(); + writeFromAnotherWindow('locale-storage', { locale: 'fr' }); + await settle(); + expect(useLocaleStore.getState().locale).toBe('en'); + }); +}); + +describe('the app-like behaviour', () => { + const css = readFileSync(join(process.cwd(), 'app/desktop.css'), 'utf8'); + const rules = css.replace(/\/\*[\s\S]*?\*\//g, '').match(/[^{}]+\{[^}]*\}/g) ?? []; + + it('hangs entirely on html[data-desktop], so browsers do not change', () => { + expect(rules.length).toBeGreaterThan(0); + for (const rule of rules) { + const selector = rule.slice(0, rule.indexOf('{')); + // Every selector of the list; commas inside :is() don't split it. + const parts = selector.split(/,(?![^(]*\))/); + for (const part of parts) expect(part.trim(), part).toMatch(/^html\[data-desktop\]/); + } + }); + + it('covers UI chrome and what the spec names', () => { + expect(css).toMatch(/user-select: none/); + expect(css).toMatch(/cursor: default/); + expect(css).toMatch(/overscroll-behavior: none/); + expect(css).toMatch(/-webkit-user-drag: none/); + }); + + it('keeps fields selectable and what the app drags draggable', () => { + expect(css).toMatch(/:is\(input, textarea, select, \[contenteditable="true"\][^{]*\{\s*-webkit-user-select: text;\s*user-select: text;/); + expect(css).toMatch(/\[draggable="true"\]\s*\{\s*-webkit-user-drag: element;/); + }); + + it('does not switch selection off for the whole page, so message content stays selectable', () => { + expect(css).not.toMatch(/html\[data-desktop\]( body)?\s*\{[^}]*user-select: none/); + }); + + function runBoot() { + new Function(desktopBootScript())(); + } + + afterEach(() => { + delete window.bulwarkDesktop; + document.documentElement.removeAttribute('data-desktop'); + }); + + it('marks the page before the first paint when the bridge is there', () => { + window.bulwarkDesktop = {}; + runBoot(); + expect(document.documentElement.hasAttribute('data-desktop')).toBe(true); + }); + + it('does not mark a browser', () => { + runBoot(); + expect(document.documentElement.hasAttribute('data-desktop')).toBe(false); + }); + + it('is in the root layout\'s boot script', () => { + const layout = readFileSync(join(process.cwd(), 'app/(main)/layout.tsx'), 'utf8'); + expect(layout).toContain('${desktopBootScript()}'); + expect(layout).toContain('../desktop.css'); + }); +}); diff --git a/lib/__tests__/fixtures/fake-broadcast-channel.ts b/lib/__tests__/fixtures/fake-broadcast-channel.ts new file mode 100644 index 000000000..fd683cbe9 --- /dev/null +++ b/lib/__tests__/fixtures/fake-broadcast-channel.ts @@ -0,0 +1,26 @@ +/** + * A same-process BroadcastChannel for tests: what one instance posts reaches + * every other open instance of that name, on a later task, never the sender. + */ +export class FakeBroadcastChannel { + static open = new Set<FakeBroadcastChannel>(); + static reset() { + FakeBroadcastChannel.open.clear(); + } + onmessage: ((event: MessageEvent) => void) | null = null; + constructor(public name: string) { + FakeBroadcastChannel.open.add(this); + } + postMessage(data: unknown) { + const copy = JSON.parse(JSON.stringify(data)); + for (const other of [...FakeBroadcastChannel.open]) { + if (other === this || other.name !== this.name) continue; + setTimeout(() => { + if (FakeBroadcastChannel.open.has(other)) other.onmessage?.({ data: copy } as MessageEvent); + }, 0); + } + } + close() { + FakeBroadcastChannel.open.delete(this); + } +} diff --git a/lib/__tests__/lite-scripts.test.ts b/lib/__tests__/lite-scripts.test.ts index 8e103daa4..5bf2edb5d 100644 --- a/lib/__tests__/lite-scripts.test.ts +++ b/lib/__tests__/lite-scripts.test.ts @@ -42,6 +42,7 @@ import { inlineScriptHashes, installStalwartFetchShim, isReservedStalwartSegment, + LITE_REMOVED_PATHS, listZipEntries, makeBuildId, normalizeBasePath, @@ -352,6 +353,8 @@ describe('prepare / postbuild / verify against a fake checkout', () => { expect(plan).toContain('proxy.ts'); expect(plan).toContain('app/api'); expect(plan).toContain('app/(main)/admin'); + // A static export can't list every message id; Lite doesn't offer pop-outs. + expect(LITE_REMOVED_PATHS).toContain('app/(main)/[locale]/popout'); expect(plan).not.toContain('app/(main)/[locale]/mail/[[...segments]]/page.tsx'); expect(plan).not.toContain('scripts/lite/smoke/lite.spec.ts'); } finally { diff --git a/lib/__tests__/popout-windows.test.ts b/lib/__tests__/popout-windows.test.ts new file mode 100644 index 000000000..35ef28022 --- /dev/null +++ b/lib/__tests__/popout-windows.test.ts @@ -0,0 +1,220 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeBroadcastChannel } from './fixtures/fake-broadcast-channel'; +import { isPopoutPath, mainWindowStorage } from '@/lib/popout/window-role'; + +// Pop-out windows: which paths are one, when they are on offer, how they open, +// and how a draft moves to one. + +function stubDisplay({ standalone = true, fine = true }: { standalone?: boolean; fine?: boolean } = {}) { + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: query.includes('pointer: fine') ? fine : query.includes('display-mode: standalone') ? standalone : false, + addEventListener() {}, + removeEventListener() {}, + })); + window.matchMedia = globalThis.matchMedia; +} + +beforeEach(() => { + FakeBroadcastChannel.reset(); + vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); + Object.defineProperty(navigator, 'locks', { value: { request: vi.fn() }, configurable: true }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + delete window.bulwarkDesktop; + Object.defineProperty(navigator, 'locks', { value: undefined, configurable: true }); + window.history.replaceState(null, '', '/'); +}); + +describe('isPopoutPath', () => { + it('knows the two routes, with or without locale and mount prefix', () => { + for (const path of ['/popout/compose', '/en/popout/compose', '/zh-TW/popout/message/abc', '/webmail/de/popout/message/abc', '/popout/compose/']) { + expect(isPopoutPath(path), path).toBe(true); + } + }); + + it('does not take other pages for one', () => { + // A folder in Files can have any name, and a mail folder too. + for (const path of ['/', '/mail', '/files/popout/compose', '/en/mail/folder/popout', '/popout', '/popout/message', '/popout/message/a/b', '/en/files/popout/message/x']) { + expect(isPopoutPath(path), path).toBe(false); + } + }); +}); + +describe('mainWindowStorage', () => { + it('lets a main window write, and a pop-out only read', () => { + localStorage.setItem('account-registry', 'before'); + const storage = mainWindowStorage(); + + storage.setItem('account-registry', 'main'); + expect(localStorage.getItem('account-registry')).toBe('main'); + + window.history.replaceState(null, '', '/en/popout/compose'); + expect(storage.getItem('account-registry')).toBe('main'); + storage.setItem('account-registry', 'popout'); + storage.removeItem('account-registry'); + expect(localStorage.getItem('account-registry')).toBe('main'); + }); +}); + +describe('popoutsAvailable', () => { + async function available() { + const { popoutsAvailable } = await import('@/lib/popout/support'); + return popoutsAvailable(); + } + + it('is off in a browser tab: the app behaves as it always has', async () => { + stubDisplay({ standalone: false }); + expect(await available()).toBe(false); + }); + + it('is on in an installed app on a device with a pointer', async () => { + stubDisplay(); + expect(await available()).toBe(true); + }); + + it('is off in an installed app on a touch device, where window.open leaves the app', async () => { + stubDisplay({ fine: false }); + expect(await available()).toBe(false); + }); + + it('asks the bridge too: display-mode is `browser` in Electron', async () => { + stubDisplay({ standalone: false }); + window.bulwarkDesktop = { windows: {} }; + expect(await available()).toBe(true); + }); + + it('is off without Web Locks or BroadcastChannel, which keep the session safe across windows', async () => { + stubDisplay(); + Object.defineProperty(navigator, 'locks', { value: undefined, configurable: true }); + expect(await available()).toBe(false); + Object.defineProperty(navigator, 'locks', { value: { request: vi.fn() }, configurable: true }); + vi.stubGlobal('BroadcastChannel', undefined); + expect(await available()).toBe(false); + }); + + it('is off in Lite, which has no such route', async () => { + vi.resetModules(); + vi.doMock('@/lib/lite', async (original) => ({ ...await original<object>(), IS_LITE: true })); + stubDisplay(); + const { popoutsAvailable } = await import('@/lib/popout/support'); + expect(popoutsAvailable()).toBe(false); + vi.doUnmock('@/lib/lite'); + vi.resetModules(); + }); +}); + +describe('opening windows', () => { + it('opens a message as a popup in the app, with its login and account', async () => { + const { openMessageWindow } = await import('@/lib/popout/open'); + const open = vi.spyOn(window, 'open').mockReturnValue({ focus: vi.fn(), closed: false } as unknown as Window); + expect(openMessageWindow({ emailId: 'm/1', localAccountId: 'login-b', ownerAccountId: 'group' })).toBe(true); + const [url, name, features] = open.mock.calls[0]; + expect(url).toBe('/popout/message/m%2F1?account=login-b&owner=group'); + expect(name).toBe('bulwark-message-m_1'); + expect(features).toMatch(/^popup=yes,width=\d+,height=\d+,left=\d+,top=\d+$/); + // In an installed app noopener sends the window to the system browser. + expect(features).not.toContain('noopener'); + }); + + it('focuses the window a message already has, without loading it again', async () => { + const { openMessageWindow } = await import('@/lib/popout/open'); + const focus = vi.fn(); + const open = vi.spyOn(window, 'open').mockReturnValue({ focus, closed: false } as unknown as Window); + openMessageWindow({ emailId: 'twice' }); + openMessageWindow({ emailId: 'twice' }); + expect(open).toHaveBeenCalledTimes(1); + expect(focus).toHaveBeenCalledTimes(2); + }); + + it('opens it again once that window was closed', async () => { + const { openMessageWindow } = await import('@/lib/popout/open'); + const first = { focus: vi.fn(), closed: false }; + const open = vi.spyOn(window, 'open').mockReturnValue(first as unknown as Window); + openMessageWindow({ emailId: 'reopened' }); + first.closed = true; + openMessageWindow({ emailId: 'reopened' }); + expect(open).toHaveBeenCalledTimes(2); + }); + + it('leaves it to the bridge when the bridge opens windows, under the same name', async () => { + const { openMessageWindow } = await import('@/lib/popout/open'); + const bridgeOpen = vi.fn(); + window.bulwarkDesktop = { windows: { open: bridgeOpen } }; + const open = vi.spyOn(window, 'open'); + expect(openMessageWindow({ emailId: 'bridged' })).toBe(true); + expect(openMessageWindow({ emailId: 'bridged' })).toBe(true); + expect(open).not.toHaveBeenCalled(); + expect(bridgeOpen.mock.calls[0][1]).toMatchObject({ name: 'bulwark-message-bridged' }); + expect(bridgeOpen.mock.calls[1][1].name).toBe(bridgeOpen.mock.calls[0][1].name); + }); + + it('says false when a blocker stops the window', async () => { + const { openComposerWindow, openMessageWindow } = await import('@/lib/popout/open'); + vi.spyOn(window, 'open').mockReturnValue(null); + expect(openMessageWindow({ emailId: 'blocked' })).toBe(false); + expect(openComposerWindow()).toBeNull(); + }); +}); + +describe('moving a draft to a pop-out', () => { + const payload = { mode: 'reply' as const, initialData: { draftId: 'saved-3', subject: 'Hi' } as never }; + const settle = (ms = 5) => new Promise((resolve) => setTimeout(resolve, ms)); + + // The module keeps its channel while a handoff is pending. + beforeEach(() => vi.resetModules()); + + it('hands the draft to the window that asks, once it is ready, under the same draft id', async () => { + const { createComposeHandoff, receiveComposeHandoff } = await import('@/lib/popout/compose-handoff'); + const handoff = createComposeHandoff()!; + const received = receiveComposeHandoff(handoff.id); + await settle(); + // The pop-out is open and waiting; the save hasn't finished. + let done = false; + void received.then(() => { done = true; }); + await settle(); + expect(done).toBe(false); + + handoff.complete(payload); + await expect(received).resolves.toMatchObject({ mode: 'reply', initialData: { draftId: 'saved-3' } }); + }); + + it('tells the pop-out when the move is called off', async () => { + const { createComposeHandoff, receiveComposeHandoff } = await import('@/lib/popout/compose-handoff'); + const handoff = createComposeHandoff()!; + const received = receiveComposeHandoff(handoff.id); + await settle(); + handoff.cancel(); + await expect(received).resolves.toBeNull(); + }); + + it('gives up when nothing comes in time', async () => { + const { receiveComposeHandoff } = await import('@/lib/popout/compose-handoff'); + await expect(receiveComposeHandoff('nobody-has-this', 10)).resolves.toBeNull(); + }); + + it('starts a reload of the pop-out from what it holds, with no opener needed', async () => { + const { rememberComposePayload, receiveComposeHandoff } = await import('@/lib/popout/compose-handoff'); + rememberComposePayload('kept', payload); + FakeBroadcastChannel.reset(); + vi.stubGlobal('BroadcastChannel', undefined); + await expect(receiveComposeHandoff('kept')).resolves.toMatchObject({ initialData: { draftId: 'saved-3' } }); + }); +}); + +describe('asking a main window for the session', () => { + it('takes the first answer and goes on without one after the timeout', async () => { + const { openPopoutChannel, requestWindowSession } = await import('@/lib/popout/channel'); + await expect(requestWindowSession({}, 10)).resolves.toBeNull(); + + const main = openPopoutChannel((message) => { + if (message.type === 'session-request') { + main?.post({ type: 'session-answer', requestId: message.requestId, answer: { activeAccountId: 'a', sessions: [] } }); + } + }); + await expect(requestWindowSession({}, 1000)).resolves.toEqual({ activeAccountId: 'a', sessions: [] }); + main?.close(); + }); +}); diff --git a/lib/__tests__/save-to-folder.test.ts b/lib/__tests__/save-to-folder.test.ts new file mode 100644 index 000000000..a7e0f6d7c --- /dev/null +++ b/lib/__tests__/save-to-folder.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { canPickFolder, createUniqueNamer, isPickerCancelled, saveFilesToFolder, type FolderHandle } from '@/lib/attachments/save-to-folder'; + +class FakeFolder implements FolderHandle { + name = 'Downloads'; + files = new Map<string, string>(); + failWriteOf: string | null = null; + removed: string[] = []; + + async getFileHandle(name: string, options?: { create?: boolean }) { + if (!this.files.has(name)) { + if (!options?.create) throw Object.assign(new Error('missing'), { name: 'NotFoundError' }); + this.files.set(name, ''); + } + return { + createWritable: async () => ({ + write: async (data: unknown) => { + if (this.failWriteOf === name) throw Object.assign(new Error('disk full'), { name: 'QuotaExceededError' }); + this.files.set(name, String(data)); + }, + close: async () => {}, + abort: async () => {}, + }), + }; + } + + async removeEntry(name: string) { + this.removed.push(name); + this.files.delete(name); + } +} + +const item = (name: string, content: string | null = name) => ({ name, read: async () => content as unknown as BufferSource | null }); +const always = () => true; + +afterEach(() => { + vi.unstubAllGlobals(); + delete (window as unknown as { showDirectoryPicker?: unknown }).showDirectoryPicker; +}); + +describe('saveFilesToFolder', () => { + it('writes each file and says how many', async () => { + const folder = new FakeFolder(); + const result = await saveFilesToFolder(folder, [item('a.txt'), item('b.txt')], always); + expect(result).toEqual({ folder: 'Downloads', total: 2, saved: 2, skipped: 0, stopped: null }); + expect(folder.files.get('a.txt')).toBe('a.txt'); + }); + + it('gives same-named attachments their own files', async () => { + const folder = new FakeFolder(); + await saveFilesToFolder(folder, [item('scan.pdf', 'one'), item('scan.pdf', 'two'), item('scan.pdf', 'three')], always); + expect([...folder.files.entries()]).toEqual([['scan.pdf', 'one'], ['scan (1).pdf', 'two'], ['scan (2).pdf', 'three']]); + }); + + it('never overwrites a file already in the folder', async () => { + const folder = new FakeFolder(); + folder.files.set('report.pdf', 'precious'); + await saveFilesToFolder(folder, [item('report.pdf', 'new')], always); + expect(folder.files.get('report.pdf')).toBe('precious'); + expect(folder.files.get('report (1).pdf')).toBe('new'); + }); + + it('stops at a failed write, keeps what is saved, and leaves no empty file', async () => { + const folder = new FakeFolder(); + folder.failWriteOf = 'b.txt'; + const result = await saveFilesToFolder(folder, [item('a.txt'), item('b.txt'), item('c.txt')], always); + expect(result).toMatchObject({ saved: 1, total: 3, stopped: 'write' }); + expect([...folder.files.keys()]).toEqual(['a.txt']); + expect(folder.removed).toEqual(['b.txt']); + }); + + it('skips a file that cannot be fetched and goes on, as the zip does', async () => { + const folder = new FakeFolder(); + const broken = { name: 'gone.bin', read: async () => { throw new Error('404'); } }; + const result = await saveFilesToFolder(folder, [item('a.txt'), broken, item('c.txt')], always); + expect(result).toMatchObject({ saved: 2, skipped: 1, stopped: null }); + }); + + it('asks the policy before every file, and a policy turning on stops the rest', async () => { + const folder = new FakeFolder(); + let asked = 0; + // On for the first file's two checks, then off. + const allowed = () => ++asked <= 2; + const read = vi.fn(async () => 'x' as unknown as BufferSource); + const result = await saveFilesToFolder(folder, [{ name: 'a', read }, { name: 'b', read }], allowed); + expect(result).toMatchObject({ saved: 1, stopped: 'policy' }); + expect(read).toHaveBeenCalledTimes(1); + expect([...folder.files.keys()]).toEqual(['a']); + }); + + it('writes nothing fetched while the policy turned on', async () => { + const folder = new FakeFolder(); + let on = true; + const read = vi.fn(async () => { on = false; return 'x' as unknown as BufferSource; }); + const result = await saveFilesToFolder(folder, [{ name: 'a', read }], () => on); + expect(result).toMatchObject({ saved: 0, stopped: 'policy' }); + expect(folder.files.size).toBe(0); + }); + + it('keeps a sender\'s path out of the file name', async () => { + const folder = new FakeFolder(); + await saveFilesToFolder(folder, [item('../../evil.exe'), item('C:\\Windows\\win.ini'), item('a:b?.txt'), item('...')], always); + expect([...folder.files.keys()]).toEqual(['evil.exe', 'win.ini', 'a_b_.txt', 'attachment']); + }); +}); + +describe('the helpers', () => { + it('sees the picker only where the browser has it', () => { + expect(canPickFolder()).toBe(false); + (window as unknown as { showDirectoryPicker: unknown }).showDirectoryPicker = () => {}; + expect(canPickFolder()).toBe(true); + }); + + it('tells a closed picker from a failure', () => { + expect(isPickerCancelled(Object.assign(new Error('x'), { name: 'AbortError' }))).toBe(true); + expect(isPickerCancelled(Object.assign(new Error('x'), { name: 'SecurityError' }))).toBe(false); + expect(isPickerCancelled(null)).toBe(false); + }); + + it('numbers zip entries like before', () => { + const name = createUniqueNamer(); + expect(['a.txt', 'a.txt', 'a.txt', '', ''].map(name)).toEqual(['a.txt', 'a (1).txt', 'a (2).txt', 'attachment', 'attachment (1)']); + }); +}); diff --git a/lib/__tests__/sw-offline-shell.test.ts b/lib/__tests__/sw-offline-shell.test.ts index 094fd3e5e..a032751e4 100644 --- a/lib/__tests__/sw-offline-shell.test.ts +++ b/lib/__tests__/sw-offline-shell.test.ts @@ -65,6 +65,45 @@ describe('service worker with offline mode on', () => { expect(await deep!.text()).toBe('<html>mail page</html>'); }); + it('keeps pop-out pages, and starts a message it never saw from the page kept for it', async () => { + const caches = new FakeCaches(); + await shellOn(caches, true); + let online = true; + const fetchMock = vi.fn(async (url: string) => { + if (!online) throw new TypeError('Failed to fetch'); + return sameOrigin(`<html>${new URL(typeof url === 'string' ? url : (url as Request).url).pathname}</html>`); + }); + const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: fetchMock as never }); + await settle(); + for (const path of ['/popout/compose', '/popout/message/_', '/']) { + const page = await worker.request({ url: `${ORIGIN}${path}`, mode: 'navigate' }); + expect(page, path).not.toBeNull(); + } + await settle(); + + online = false; + const message = await worker.request({ url: `${ORIGIN}/popout/message/never-seen`, mode: 'navigate' }); + expect(await message!.text()).toBe('<html>/popout/message/_</html>'); + const compose = await worker.request({ url: `${ORIGIN}/popout/compose?handoff=x`, mode: 'navigate' }); + expect(await compose!.text()).toBe('<html>/popout/compose</html>'); + }); + + it('never puts the whole app into a pop-out\'s window', async () => { + const caches = new FakeCaches(); + await shellOn(caches, true); + let online = true; + const fetchMock = vi.fn(async () => { + if (!online) throw new TypeError('Failed to fetch'); + return sameOrigin('<html>the whole app</html>'); + }); + const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: fetchMock }); + await settle(); + await worker.request({ url: `${ORIGIN}/`, mode: 'navigate' }); + await settle(); + online = false; + await expect(worker.request({ url: `${ORIGIN}/popout/compose`, mode: 'navigate' })).rejects.toThrow('Failed to fetch'); + }); + it('serves a chunk of an older build from that build\'s cache, and keeps new ones per build', async () => { const caches = new FakeCaches(); await shellOn(caches, true); diff --git a/lib/__tests__/token-lock.test.ts b/lib/__tests__/token-lock.test.ts new file mode 100644 index 000000000..feab22fb4 --- /dev/null +++ b/lib/__tests__/token-lock.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { tokenLockName, withTokenLock } from '@/lib/auth/token-lock'; + +/** Web Locks with one holder at a time and waiters in order, like the browser's. */ +function fakeLocks() { + const queues = new Map<string, Promise<unknown>>(); + const names: string[] = []; + return { + names, + request(name: string, options: { signal?: AbortSignal }, callback: () => Promise<unknown>) { + names.push(name); + const previous = queues.get(name) ?? Promise.resolve(); + const run = new Promise((resolve, reject) => { + let aborted = false; + options.signal?.addEventListener('abort', () => { + aborted = true; + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + }); + void previous.catch(() => {}).then(() => { + if (aborted) return; + Promise.resolve(callback()).then(resolve, reject); + }); + }); + queues.set(name, run.catch(() => {})); + return run; + }, + }; +} + +function stubLocks(locks: unknown) { + Object.defineProperty(navigator, 'locks', { value: locks, configurable: true }); +} + +afterEach(() => { + stubLocks(undefined); + vi.restoreAllMocks(); +}); + +describe('withTokenLock', () => { + it('runs one renewal per slot at a time, so the second sends the token the first wrote', async () => { + stubLocks(fakeLocks()); + const order: string[] = []; + let releaseFirst = () => {}; + const first = withTokenLock(1, () => new Promise<string>((resolve) => { + order.push('first starts'); + releaseFirst = () => { + order.push('first ends'); + resolve('a'); + }; + })); + const second = withTokenLock(1, async () => { + order.push('second starts'); + return 'b'; + }); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(order).toEqual(['first starts']); + releaseFirst(); + await expect(Promise.all([first, second])).resolves.toEqual(['a', 'b']); + expect(order).toEqual(['first starts', 'first ends', 'second starts']); + }); + + it('keeps slots apart: each login has its own refresh token', async () => { + const locks = fakeLocks(); + stubLocks(locks); + await withTokenLock(0, async () => 'x'); + await withTokenLock(2, async () => 'y'); + expect(locks.names).toEqual([tokenLockName(0), tokenLockName(2)]); + expect(tokenLockName(0)).not.toBe(tokenLockName(2)); + }); + + it('runs at once without Web Locks', async () => { + await expect(withTokenLock(1, async () => 'done')).resolves.toBe('done'); + }); + + it('goes ahead when another window holds the lock too long, rather than never renewing', async () => { + stubLocks(fakeLocks()); + void withTokenLock(1, () => new Promise(() => {})); + await expect(withTokenLock(1, async () => 'went ahead', 10)).resolves.toBe('went ahead'); + }); + + it('hands the work\'s own error to the caller and does not run it twice', async () => { + stubLocks(fakeLocks()); + const work = vi.fn(async () => { throw new TypeError('Failed to fetch'); }); + await expect(withTokenLock(1, work)).rejects.toThrow('Failed to fetch'); + expect(work).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/__tests__/window-title-bar.test.tsx b/lib/__tests__/window-title-bar.test.tsx new file mode 100644 index 000000000..b0d4bb11b --- /dev/null +++ b/lib/__tests__/window-title-bar.test.tsx @@ -0,0 +1,130 @@ +import { act, cleanup, render } from '@testing-library/react'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { WindowTitleBar } from '@/components/layout/window-title-bar'; +import { TITLEBAR_INSET_VAR, readTitlebarArea } from '@/lib/window-controls-overlay'; + +class FakeOverlay extends EventTarget { + visible = true; + rect = { x: 0, y: 0, width: 867, height: 40 }; + getTitlebarAreaRect() { + return this.rect; + } + change(next: Partial<FakeOverlay>) { + Object.assign(this, next); + this.dispatchEvent(new Event('geometrychange')); + } +} + +function installOverlay(overlay: FakeOverlay | undefined) { + Object.defineProperty(navigator, 'windowControlsOverlay', { value: overlay, configurable: true }); +} + +const inset = () => document.documentElement.style.getPropertyValue(TITLEBAR_INSET_VAR); +const strip = () => document.querySelector<HTMLElement>('[data-window-title-bar]'); + +afterEach(() => { + cleanup(); + installOverlay(undefined); + document.documentElement.style.removeProperty(TITLEBAR_INSET_VAR); +}); + +describe('the title bar', () => { + it('shows nothing and touches nothing without the API', () => { + installOverlay(undefined); + expect(readTitlebarArea()).toBeNull(); + render(<WindowTitleBar />); + expect(strip()).toBeNull(); + expect(inset()).toBe(''); + }); + + it('shows nothing when the overlay is there but hidden', () => { + const overlay = new FakeOverlay(); + overlay.visible = false; + installOverlay(overlay); + render(<WindowTitleBar />); + expect(strip()).toBeNull(); + expect(inset()).toBe(''); + }); + + it('draws the strip next to the controls and pushes the page down', () => { + installOverlay(new FakeOverlay()); + document.title = 'Inbox - Mail'; + render(<WindowTitleBar />); + const bar = strip()!; + expect(bar.style.width).toBe('867px'); + expect(bar.style.height).toBe('40px'); + expect(bar.textContent).toBe('Inbox - Mail'); + expect(bar.classList.contains('window-title-bar')).toBe(true); + expect(inset()).toBe('40px'); + }); + + it('stays out of the accessibility tree and the tab order', () => { + installOverlay(new FakeOverlay()); + render(<WindowTitleBar />); + expect(strip()!.getAttribute('aria-hidden')).toBe('true'); + expect(strip()!.querySelector('a, button, input, [tabindex]')).toBeNull(); + }); + + it('follows geometrychange, and takes everything back when the overlay goes', () => { + const overlay = new FakeOverlay(); + installOverlay(overlay); + render(<WindowTitleBar />); + act(() => overlay.change({ rect: { x: 72, y: 0, width: 500, height: 32 } })); + expect(strip()!.style.left).toBe('72px'); + expect(inset()).toBe('32px'); + act(() => overlay.change({ visible: false })); + expect(strip()).toBeNull(); + expect(inset()).toBe(''); + }); + + it('follows the window title as the surface changes it', async () => { + installOverlay(new FakeOverlay()); + document.title = 'First'; + render(<WindowTitleBar />); + await act(async () => { + document.title = 'Second'; + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(strip()!.textContent).toBe('Second'); + }); +}); + +describe('the page roots and the stylesheet', () => { + const root = process.cwd(); + const css = readFileSync(join(root, 'app/globals.css'), 'utf8'); + + it('is 0 without an overlay, and exactly the old safe area then', () => { + expect(css).toContain('--titlebar-inset: env(titlebar-area-height, 0px);'); + expect(css).toContain('--app-top-inset: max(env(safe-area-inset-top), var(--titlebar-inset));'); + }); + + it('does not key on the display-mode media query, which is false in Electron', () => { + expect(css).not.toMatch(/display-mode:\s*window-controls-overlay/); + }); + + it('makes the strip the part that drags the window', () => { + expect(css).toMatch(/\.window-title-bar\s*\{[^}]*app-region:\s*drag/); + }); + + function tsxFiles(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + if (name === '__tests__' || name === 'node_modules') return []; + return statSync(path).isDirectory() ? tsxFiles(path) : name.endsWith('.tsx') ? [path] : []; + }); + } + + it('leaves no page root or top bar on the bare safe area, skeletons included', () => { + const left = [...tsxFiles(join(root, 'components')), ...tsxFiles(join(root, 'app'))] + .filter((path) => /pt-\[(?:calc\([^\]]*)?env\(safe-area-inset-top/.test(readFileSync(path, 'utf8'))); + expect(left).toEqual([]); + }); + + it('gives each skeleton the same top inset as the page it stands in for', () => { + for (const file of ['components/boot-skeleton/shell.tsx', 'components/boot-skeleton/pro-skeleton.tsx', 'components/boot-skeleton/admin-skeleton.tsx', 'components/mail/mail-app.tsx']) { + expect(readFileSync(join(root, file), 'utf8'), file).toContain('pt-[var(--app-top-inset)]'); + } + }); +}); diff --git a/lib/attachments/save-to-folder.ts b/lib/attachments/save-to-folder.ts new file mode 100644 index 000000000..a5538e8eb --- /dev/null +++ b/lib/attachments/save-to-folder.ts @@ -0,0 +1,170 @@ +/** + * "Save all attachments to a folder", with the File System Access API. + * + * These writes don't pass saveBlobAs (lib/download-guard.ts), the backstop of + * the device policies, so the caller's `allowed` is asked before each file: + * a policy that turns on while the files are written stops the rest. + * + * The folder handle is an interface, not the DOM type, so the desktop bridge + * or a test can supply its own. + */ + +export interface WritableFile { + write(data: Blob | BufferSource): Promise<void>; + close(): Promise<void>; + abort?(): Promise<void>; +} + +export interface FolderFile { + createWritable(): Promise<WritableFile>; +} + +export interface FolderHandle { + name: string; + getFileHandle(name: string, options?: { create?: boolean }): Promise<FolderFile>; + removeEntry?(name: string): Promise<void>; +} + +type DirectoryPicker = (options?: { mode?: 'read' | 'readwrite'; id?: string; startIn?: string }) => Promise<FolderHandle>; + +/** Whether this browser can ask for a folder. Without it the zip download is all there is. */ +export function canPickFolder(): boolean { + return typeof window !== 'undefined' && typeof (window as unknown as { showDirectoryPicker?: unknown }).showDirectoryPicker === 'function'; +} + +/** + * Asks where to save. Call it straight from the click, before any await: the + * browser only shows the picker for a user action. Rejects with an AbortError + * when the user closes the picker (see isPickerCancelled). + */ +export function pickFolder(): Promise<FolderHandle> { + const picker = (window as unknown as { showDirectoryPicker: DirectoryPicker }).showDirectoryPicker; + return picker.call(window, { mode: 'readwrite', id: 'bulwark-attachments', startIn: 'downloads' }); +} + +export function isPickerCancelled(error: unknown): boolean { + return (error as { name?: unknown } | null)?.name === 'AbortError'; +} + +/** The name's nth alternative: " (n)" before the extension, so duplicates stay recognisable. */ +function numberedName(base: string, n: number): string { + if (n === 0) return base; + const dot = base.lastIndexOf('.'); + const stem = dot > 0 ? base.slice(0, dot) : base; + const ext = dot > 0 ? base.slice(dot) : ''; + return `${stem} (${n})${ext}`; +} + +/** Names unique within one batch, for the zip's entries. */ +export function createUniqueNamer(fallback = 'attachment'): (raw: string) => string { + const used = new Set<string>(); + return (raw) => { + const base = raw || fallback; + let n = 0; + while (used.has(numberedName(base, n))) n++; + const name = numberedName(base, n); + used.add(name); + return name; + }; +} + +/** Characters no file system takes in a name, and path parts a sender could smuggle in. */ +function safeFileName(raw: string, fallback: string): string { + const leaf = raw.split(/[\\/]/).pop() ?? ''; + // eslint-disable-next-line no-control-regex + const cleaned = leaf.replace(/[<>:"|?*\x00-\x1f]/g, '_').replace(/[. ]+$/, '').trim(); + return cleaned && cleaned !== '.' && cleaned !== '..' ? cleaned : fallback; +} + +async function existsInFolder(folder: FolderHandle, name: string): Promise<boolean> { + try { + await folder.getFileHandle(name); + return true; + } catch (error) { + const kind = (error as { name?: unknown } | null)?.name; + if (kind === 'NotFoundError') return false; + // A sub-folder of that name: taken as well. + if (kind === 'TypeMismatchError') return true; + throw error; + } +} + +/** The first of the name's alternatives neither this batch nor the folder has. */ +async function freeFileName(folder: FolderHandle, base: string, claimed: ReadonlySet<string>): Promise<string> { + let n = 0; + while (claimed.has(numberedName(base, n)) || await existsInFolder(folder, numberedName(base, n))) n++; + return numberedName(base, n); +} + +export interface FolderSaveItem { + name: string; + /** The file's content, or null when it has none to save. Fetched only when its turn comes. */ + read: () => Promise<Blob | BufferSource | null>; +} + +export interface FolderSaveResult { + folder: string; + total: number; + saved: number; + /** Files that couldn't be read from the server; the rest still went. */ + skipped: number; + /** Why the run ended early, when it did. */ + stopped: 'policy' | 'write' | null; +} + +/** + * Writes the files one at a time. A file that can't be fetched is skipped, + * like in the zip. A write that fails stops the run, because the next one + * would fail the same way (disk full, permission gone), and the result says + * how many made it. A file already in the folder is never overwritten. + */ +export async function saveFilesToFolder( + folder: FolderHandle, + items: readonly FolderSaveItem[], + allowed: () => boolean, +): Promise<FolderSaveResult> { + const result: FolderSaveResult = { folder: folder.name, total: items.length, saved: 0, skipped: 0, stopped: null }; + const claimed = new Set<string>(); + + for (const item of items) { + if (!allowed()) { + result.stopped = 'policy'; + return result; + } + + let data: Blob | BufferSource | null; + try { + data = await item.read(); + } catch { + data = null; + } + if (data === null) { + result.skipped += 1; + continue; + } + // The policy may have changed during the fetch, and nothing is on disk yet. + if (!allowed()) { + result.stopped = 'policy'; + return result; + } + + let name: string | null = null; + let writable: WritableFile | null = null; + try { + name = await freeFileName(folder, safeFileName(item.name, 'attachment'), claimed); + claimed.add(name); + const file = await folder.getFileHandle(name, { create: true }); + writable = await file.createWritable(); + await writable.write(data); + await writable.close(); + result.saved += 1; + } catch { + await writable?.abort?.().catch(() => {}); + // getFileHandle created an empty file; don't leave it looking saved. + if (name) await folder.removeEntry?.(name).catch(() => {}); + result.stopped = 'write'; + return result; + } + } + return result; +} diff --git a/lib/auth/token-lock.ts b/lib/auth/token-lock.ts new file mode 100644 index 000000000..80a9a9fa6 --- /dev/null +++ b/lib/auth/token-lock.ts @@ -0,0 +1,51 @@ +/** + * One token renewal at a time across the windows of this browser. + * + * Refresh tokens rotate, and the token route reads the current one from a + * cookie. Two windows renewing at once send the same token twice; on Stalwart + * 1.0 a superseded token reused past its retry window counts as a replay and + * revokes the whole grant. Held across the request, so the next window's + * request carries the cookie the first one's answer wrote. + * + * Without Web Locks, or after waiting too long for a window that hangs, the + * work runs anyway: never worse than having no lock. + */ + +interface LockManagerLike { + request<T>(name: string, options: { signal?: AbortSignal }, callback: () => Promise<T>): Promise<T>; +} + +/** How long a renewal waits for another window's before going ahead. Above the route's own timeouts. */ +export const TOKEN_LOCK_WAIT_MS = 30_000; + +function lockManager(): LockManagerLike | null { + const locks = typeof navigator !== 'undefined' ? (navigator as { locks?: LockManagerLike }).locks : undefined; + return locks && typeof locks.request === 'function' ? locks : null; +} + +export function tokenLockName(slot: number): string { + return `bulwark-token-refresh:${slot}`; +} + +export async function withTokenLock<T>(slot: number, work: () => Promise<T>, waitMs: number = TOKEN_LOCK_WAIT_MS): Promise<T> { + const locks = lockManager(); + if (!locks) return work(); + + const gaveUp = new AbortController(); + const timer = setTimeout(() => gaveUp.abort(), waitMs); + let started = false; + try { + return await locks.request(tokenLockName(slot), { signal: gaveUp.signal }, () => { + started = true; + clearTimeout(timer); + return work(); + }); + } catch (error) { + // Only the lock itself failing runs the work: an error from the work + // (started) goes to the caller, who decides what it means. + if (started) throw error; + return work(); + } finally { + clearTimeout(timer); + } +} diff --git a/lib/desktop/boot.ts b/lib/desktop/boot.ts new file mode 100644 index 000000000..01f68c7a6 --- /dev/null +++ b/lib/desktop/boot.ts @@ -0,0 +1,8 @@ +/** + * Runs in the inline boot script, before the first paint. The preload bridge + * exists before any page script, so app/desktop.css holds from the first + * frame. Nothing happens in a browser, which has no bridge. + */ +export function desktopBootScript(): string { + return `if (window.bulwarkDesktop) document.documentElement.setAttribute('data-desktop', '');`; +} diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts new file mode 100644 index 000000000..a134358ca --- /dev/null +++ b/lib/desktop/types.ts @@ -0,0 +1,26 @@ +/** + * The desktop app's preload bridge as the webmail sees it. Feature checks ask + * `window.bulwarkDesktop?.<area>`; in a browser the object isn't there. + * + * Only what the webmail uses so far. The desktop shell fills it in. + */ + +export interface DesktopWindowsBridge { + /** + * Opens a pop-out window itself. A `name` that is already open focuses that + * window and doesn't load `url` again: one window per message. Left out, the + * page uses window.open with a features string, which the shell's + * window-open handler sees too. + */ + open?: (url: string, options: { name: string; width: number; height: number }) => void; +} + +export interface BulwarkDesktopBridge { + windows?: DesktopWindowsBridge; +} + +declare global { + interface Window { + bulwarkDesktop?: BulwarkDesktopBridge; + } +} diff --git a/lib/desktop/window-sync.ts b/lib/desktop/window-sync.ts new file mode 100644 index 000000000..c89514680 --- /dev/null +++ b/lib/desktop/window-sync.ts @@ -0,0 +1,42 @@ +/** + * Keeps what the windows of the app share up to date without a reload: theme, + * language and settings. Each is a persisted store, so a change in one window + * reaches the others as a `storage` event. Read state and drafts go through + * lib/popout/channel.ts, and each window keeps its own account (see + * local-data/desktop-offline-decisions.md). + * + * Only in pop-outs and the desktop app: tabs of a browser keep today's + * behaviour, where a change shows at the next load. + */ + +import { useLocaleStore } from '@/stores/locale-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useThemeStore } from '@/stores/theme-store'; +import { hasDesktopWindows } from '@/lib/popout/support'; +import { isPopoutWindow } from '@/lib/popout/window-role'; + +export function windowSyncWanted(): boolean { + return isPopoutWindow() || hasDesktopWindows(); +} + +/** Starts listening; returns the stop. Does nothing where windowSyncWanted says no. */ +export function startWindowSync(): () => void { + if (!windowSyncWanted()) return () => {}; + const onStorage = (event: StorageEvent) => { + switch (event.key) { + case 'theme-storage': + // Rehydrating sets the state; this puts it on the page. + void Promise.resolve(useThemeStore.persist.rehydrate()).then(() => useThemeStore.getState().initializeTheme()); + break; + case 'locale-storage': + // IntlProvider switches the catalog when the store changes. + void useLocaleStore.persist.rehydrate(); + break; + case 'settings-storage': + void useSettingsStore.persist.rehydrate(); + break; + } + }; + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); +} diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 6d35dcdfa..5536ac710 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1683,6 +1683,25 @@ export class JMAPClient implements IJMAPClient { return client; } + /** + * A client on a credential another window of this browser holds (a pop-out, + * lib/popout): the header as that window sends it, whatever kind it is. + */ + static withSharedAuth( + serverUrl: string, + username: string, + authHeader: string, + onTokenRefresh?: () => Promise<string | null>, + ): JMAPClient { + const client = new JMAPClient(serverUrl, username, ''); + client.authHeader = authHeader; + if (authHeader.startsWith('Bearer ')) { + client.authMode = 'bearer'; + client.onTokenRefresh = onTokenRefresh; + } + return client; + } + updateAccessToken(token: string): void { this.authHeader = `Bearer ${token}`; } diff --git a/lib/offline/shell.ts b/lib/offline/shell.ts index 88aa07b4e..7059ff739 100644 --- a/lib/offline/shell.ts +++ b/lib/offline/shell.ts @@ -8,7 +8,7 @@ import { apiFetch, withBasePath } from '@/lib/browser-navigation'; import { currentBuildId, SW_SCOPE } from '@/lib/app-update'; /** The app's pages kept for a start without the network. */ -const SHELL_PAGES = ['/', '/mail', '/settings']; +const SHELL_PAGES = ['/', '/mail', '/settings', '/popout/compose', '/popout/message/_']; /** * The files of this tab's build. After a deploy the server lists the new diff --git a/lib/popout/channel.ts b/lib/popout/channel.ts new file mode 100644 index 000000000..c92266c40 --- /dev/null +++ b/lib/popout/channel.ts @@ -0,0 +1,226 @@ +/** + * What the windows of this browser say to each other about pop-outs. A + * BroadcastChannel, because a window opened by the desktop bridge has no + * opener, and a pop-out outlives a reload of the window that opened it. + * + * Session answers carry the auth header. A channel reaches same-origin pages + * only, and those can already ask PUT /api/auth/session for the credentials; + * plugin frames of the untrusted tier have an opaque origin and aren't on it. + */ + +import type { ComposePopoutPayload } from './compose-payload'; + +export const POPOUT_CHANNEL = 'bulwark:popout-windows'; + +/** One connected login, as the window that holds it sees it. */ +export interface SharedAccountSession { + accountId: string; + serverUrl: string; + username: string; + authMode: 'basic' | 'token' | 'oauth'; + authHeader: string; + /** The window started offline: it has no credential to share (#523). */ + offlineOnly?: boolean; +} + +export interface SharedSessionAnswer { + activeAccountId: string | null; + sessions: SharedAccountSession[]; +} + +/** The send's outcome, for the main window to say and to offer Undo. The pop-out has closed by then. */ +export interface PopoutSentNotice { + accountId: string | null; + scheduled?: boolean; + queued?: boolean; + filingError?: boolean; + rejectedRecipients?: string; + /** stores/email-store.ts PendingUndoSend. */ + pendingUndoSend?: { + submissionId: string; + emailId?: string; + identityId?: string; + sendAt: string; + isSmime: boolean; + localAccountId?: string; + submissionAccountId?: string; + } | null; +} + +/** + * A draft whose window is closing, ready to save. A request started while a + * window closes doesn't survive, and saving takes several, so another window + * does it. The arguments of JMAPClient.createDraft, as plain data. + */ +export interface RescuedDraft { + /** The login to save through; absent, the window's active one. */ + localAccountId?: string; + to: string[]; + subject: string; + body: string; + cc: string[]; + bcc: string[]; + identityId?: string; + fromEmail?: string; + /** The version this one replaces. */ + draftId?: string; + attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; + fromName?: string; + htmlBody?: string; + inReplyTo?: string[]; + references?: string[]; + options?: { priority?: import('@/lib/message-priority').MessagePriority; previousDraftAccountId?: string; classification?: string | null }; +} + +export type PopoutMessage = + | { type: 'session-request'; requestId: string; accountId?: string; rejectedAuthHeader?: string } + | { type: 'session-answer'; requestId: string; answer: SharedSessionAnswer } + | { type: 'handoff-request'; handoffId: string } + | { type: 'handoff-answer'; handoffId: string; payload: ComposePopoutPayload | null } + | { type: 'sent'; requestId: string; notice: PopoutSentNotice } + | { type: 'sent-ack'; requestId: string } + | { type: 'mail-changed'; accountId: string | null } + | { type: 'state-change' } + | { type: 'draft-rescue'; draft: RescuedDraft } + | { type: 'main-ping' } + | { type: 'main-pong' } + | { type: 'main-gone' } + | { type: 'account-removed'; accountId: string }; + +export interface PopoutChannel { + post(message: PopoutMessage): void; + close(): void; +} + +/** Null without BroadcastChannel: pop-outs aren't offered then (lib/popout/support.ts). */ +export function openPopoutChannel(onMessage: (message: PopoutMessage) => void): PopoutChannel | null { + if (typeof BroadcastChannel === 'undefined') return null; + let channel: BroadcastChannel; + try { + channel = new BroadcastChannel(POPOUT_CHANNEL); + } catch { + return null; + } + channel.onmessage = (event: MessageEvent) => { + const message = event.data as PopoutMessage | null; + if (message && typeof message.type === 'string') onMessage(message); + }; + return { + post(message) { + try { + channel.postMessage(message); + } catch { + // The channel is closed, or the page is going away. + } + }, + close() { + channel.close(); + }, + }; +} + +function newId(): string { + return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +} + +export { newId as newPopoutId }; + +/** How long a pop-out waits for a main window before it goes on without one. */ +export const SESSION_ANSWER_MS = 3000; + +/** + * The session of a window that is signed in, from any main window. With + * `rejectedAuthHeader` the server just refused that credential: the main + * window renews it first when it still holds the same one. Null when no main + * window answers in time. + */ +export function requestWindowSession( + options: { accountId?: string; rejectedAuthHeader?: string } = {}, + timeoutMs: number = SESSION_ANSWER_MS, +): Promise<SharedSessionAnswer | null> { + return new Promise((resolve) => { + const requestId = newId(); + let timer: ReturnType<typeof setTimeout> | null = null; + const channel = openPopoutChannel((message) => { + if (message.type !== 'session-answer' || message.requestId !== requestId) return; + finish(message.answer); + }); + const finish = (answer: SharedSessionAnswer | null) => { + if (timer) clearTimeout(timer); + channel?.close(); + resolve(answer); + }; + if (!channel) { + resolve(null); + return; + } + timer = setTimeout(() => finish(null), timeoutMs); + channel.post({ type: 'session-request', requestId, ...options }); + }); +} + +/** Tells a main window what was sent. True when one took it over. */ +export function announceSentToMainWindow(notice: PopoutSentNotice, timeoutMs = 500): Promise<boolean> { + return new Promise((resolve) => { + const requestId = newId(); + let timer: ReturnType<typeof setTimeout> | null = null; + const channel = openPopoutChannel((message) => { + if (message.type === 'sent-ack' && message.requestId === requestId) finish(true); + }); + const finish = (taken: boolean) => { + if (timer) clearTimeout(timer); + channel?.close(); + resolve(taken); + }; + if (!channel) { + resolve(false); + return; + } + timer = setTimeout(() => finish(false), timeoutMs); + channel.post({ type: 'sent', requestId, notice }); + }); +} + +/** Tells the main windows that this pop-out changed mail they may be showing. */ +export function announceMailChanged(accountId: string | null): void { + const channel = openPopoutChannel(() => {}); + channel?.post({ type: 'mail-changed', accountId }); + channel?.close(); +} + +/** How often a pop-out checks that a main window is there, and how long an answer counts. */ +const MAIN_PING_MS = 4000; +const MAIN_ALIVE_MS = 10_000; + +/** + * Keeps track, synchronously, of whether a main window is open: a window + * closing has no time to ask. Also hands a closing draft to it. + */ +export function watchMainWindow(): { alive(): boolean; rescue(draft: RescuedDraft): boolean; stop(): void } { + let lastSeen = 0; + const channel = openPopoutChannel((message) => { + if (message.type === 'main-pong') lastSeen = Date.now(); + if (message.type === 'main-gone') { + lastSeen = 0; + // Another main window takes over, and answers this. + channel?.post({ type: 'main-ping' }); + } + }); + channel?.post({ type: 'main-ping' }); + const timer = setInterval(() => channel?.post({ type: 'main-ping' }), MAIN_PING_MS); + const alive = () => Date.now() - lastSeen < MAIN_ALIVE_MS; + return { + alive, + rescue(draft) { + if (!channel || !alive()) return false; + channel.post({ type: 'draft-rescue', draft }); + return true; + }, + stop() { + clearInterval(timer); + channel?.close(); + }, + }; +} diff --git a/lib/popout/compose-handoff.ts b/lib/popout/compose-handoff.ts new file mode 100644 index 000000000..2d84bcc2f --- /dev/null +++ b/lib/popout/compose-handoff.ts @@ -0,0 +1,122 @@ +/** + * Moves a composer into a pop-out. The window has to open inside the click, + * but the draft is only ready once it is saved, so the opening window keeps + * a pending handoff that the pop-out asks for by id and waits on. + */ + +import { openPopoutChannel, newPopoutId, type PopoutChannel } from './channel'; +import type { ComposePopoutPayload } from './compose-payload'; + +/** A handoff nobody asked for after this long is dropped. Above the time a save can take. */ +const PENDING_TTL_MS = 120_000; +/** How long a pop-out waits for its draft. */ +export const HANDOFF_WAIT_MS = 60_000; +/** How often a pop-out asks again: the request may cross a reload of the window that opened it. */ +const HANDOFF_RETRY_MS = 1000; + +const STORAGE_PREFIX = 'bulwark-popout-compose:'; + +interface Pending { + requested: boolean; + /** Undefined until the opening window decided; null means it won't come. */ + payload?: ComposePopoutPayload | null; + timer: ReturnType<typeof setTimeout>; +} + +const pending = new Map<string, Pending>(); +let channel: PopoutChannel | null = null; + +function deliver(handoffId: string, entry: Pending): void { + if (!entry.requested || entry.payload === undefined) return; + channel?.post({ type: 'handoff-answer', handoffId, payload: entry.payload }); + forget(handoffId); +} + +function forget(handoffId: string): void { + const entry = pending.get(handoffId); + if (entry) clearTimeout(entry.timer); + pending.delete(handoffId); + if (pending.size === 0) { + channel?.close(); + channel = null; + } +} + +export interface ComposeHandoff { + id: string; + complete(payload: ComposePopoutPayload): void; + cancel(): void; +} + +/** Null without BroadcastChannel. */ +export function createComposeHandoff(): ComposeHandoff | null { + channel ??= openPopoutChannel((message) => { + if (message.type !== 'handoff-request') return; + const entry = pending.get(message.handoffId); + if (!entry) return; + entry.requested = true; + deliver(message.handoffId, entry); + }); + if (!channel) return null; + + const id = newPopoutId(); + pending.set(id, { requested: false, timer: setTimeout(() => forget(id), PENDING_TTL_MS) }); + const settle = (payload: ComposePopoutPayload | null) => { + const entry = pending.get(id); + if (!entry) return; + entry.payload = payload; + deliver(id, entry); + }; + return { id, complete: (payload) => settle(payload), cancel: () => settle(null) }; +} + +/** Keeps the draft with this window, so a reload starts from it. */ +export function rememberComposePayload(handoffId: string, payload: ComposePopoutPayload): void { + try { + sessionStorage.setItem(STORAGE_PREFIX + handoffId, JSON.stringify(payload)); + } catch { + // Full or unavailable: a reload then starts from the saved draft's first state. + } +} + +function rememberedComposePayload(handoffId: string): ComposePopoutPayload | null { + try { + const raw = sessionStorage.getItem(STORAGE_PREFIX + handoffId); + return raw ? (JSON.parse(raw) as ComposePopoutPayload) : null; + } catch { + return null; + } +} + +/** + * The pop-out's side. Null when the opening window called it off, or nothing + * came in time. + */ +export function receiveComposeHandoff(handoffId: string, timeoutMs: number = HANDOFF_WAIT_MS): Promise<ComposePopoutPayload | null> { + const remembered = rememberedComposePayload(handoffId); + if (remembered) return Promise.resolve(remembered); + + return new Promise((resolve) => { + let retry: ReturnType<typeof setInterval> | null = null; + let timer: ReturnType<typeof setTimeout> | null = null; + const listener = openPopoutChannel((message) => { + if (message.type !== 'handoff-answer' || message.handoffId !== handoffId) return; + if (message.payload) rememberComposePayload(handoffId, message.payload); + finish(message.payload); + }); + const finish = (payload: ComposePopoutPayload | null) => { + if (retry) clearInterval(retry); + if (timer) clearTimeout(timer); + listener?.close(); + resolve(payload); + }; + if (!listener) { + resolve(null); + return; + } + const ask = () => listener.post({ type: 'handoff-request', handoffId }); + timer = setTimeout(() => finish(null), timeoutMs); + retry = setInterval(ask, HANDOFF_RETRY_MS); + ask(); + }); +} diff --git a/lib/popout/compose-payload.ts b/lib/popout/compose-payload.ts new file mode 100644 index 000000000..714bfc1ea --- /dev/null +++ b/lib/popout/compose-payload.ts @@ -0,0 +1,23 @@ +/** + * What a composer needs to start in another window. Plain data: it crosses a + * BroadcastChannel and is kept in the pop-out's sessionStorage over a reload. + * Attachments are blob references, already on the server. + */ + +import type { ComposerDraftData } from '@/components/email/email-composer'; +import type { ComposePrefill } from '@/lib/plugin-compose-gate'; +import type { EmailWriteSource } from '@/stores/email-store'; +import type { ProComposerMode, ProReplyContext } from '@/stores/pro-tab-store'; + +export interface ComposePopoutPayload { + mode: ProComposerMode; + replyTo?: ProReplyContext; + initialDraftText?: string; + initialData?: ComposerDraftData | null; + prefill?: ComposePrefill | null; + /** The message replied to or forwarded, for its $answered / $forwarded flag. */ + sourceEmailId?: string | null; + sourceEmailSource?: EmailWriteSource | null; + /** The mailbox a new message was started from; picks the identity (see the composer's prop). */ + composeFromAccountEmail?: string; +} diff --git a/lib/popout/open.ts b/lib/popout/open.ts new file mode 100644 index 000000000..cbed686aa --- /dev/null +++ b/lib/popout/open.ts @@ -0,0 +1,100 @@ +/** + * Opens pop-out windows: one message, or a composer. Shared with the desktop + * app's quick compose, which only has to call openComposerWindow. + */ + +import { appPath } from '@/lib/deep-links'; +import '@/lib/desktop/types'; +import { createComposeHandoff, type ComposeHandoff } from './compose-handoff'; +import type { ComposePopoutPayload } from './compose-payload'; + +const SIZES = { + message: { width: 960, height: 820 }, + compose: { width: 760, height: 780 }, +} as const; + +type PopoutKind = keyof typeof SIZES; + +/** Offset from the opening window, so the new one doesn't hide it exactly. */ +const CASCADE_PX = 48; + +export function popoutFeatures(kind: PopoutKind): string { + const { width, height } = SIZES[kind]; + const left = Math.round((window.screenX || 0) + CASCADE_PX); + const top = Math.round((window.screenY || 0) + CASCADE_PX); + return `popup=yes,width=${width},height=${height},left=${left},top=${top}`; +} + +/** Windows this page opened. window.open with a name would load the address in it again. */ +const openedWindows = new Map<string, Window>(); + +/** Whether a window opened. False when a pop-up blocker said no: the caller stays as it is. */ +function openWindow(url: string, name: string, kind: PopoutKind): Window | boolean { + const bridge = window.bulwarkDesktop?.windows; + if (bridge?.open) { + bridge.open(url, { name, ...SIZES[kind] }); + return true; + } + const existing = openedWindows.get(name); + if (existing && !existing.closed) return existing; + // Not noopener: in an installed app that is what sends a window to the + // system browser (lib/standalone-display.ts). + const opened = window.open(url, name, popoutFeatures(kind)); + if (!opened) return false; + openedWindows.set(name, opened); + return opened; +} + +function focused(opened: Window | boolean): boolean { + if (opened && typeof opened !== 'boolean') opened.focus?.(); + return !!opened; +} + +function query(params: Record<string, string | null | undefined>): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) if (value) search.set(key, value); + const text = search.toString(); + return text ? `?${text}` : ''; +} + +export interface MessageWindowTarget { + emailId: string; + /** The login the message is read through. */ + localAccountId?: string | null; + /** The JMAP account holding it, for a shared or group folder. */ + ownerAccountId?: string | null; +} + +export function messagePopoutUrl(target: MessageWindowTarget): string { + return appPath(`/popout/message/${encodeURIComponent(target.emailId)}`) + + query({ account: target.localAccountId, owner: target.ownerAccountId }); +} + +/** The same message twice focuses the same window, without loading it again. */ +export function openMessageWindow(target: MessageWindowTarget): boolean { + return focused(openWindow(messagePopoutUrl(target), `bulwark-message-${target.emailId.replace(/\W/g, '_')}`, 'message')); +} + +export interface ComposerWindow { + /** Hands the draft over. The pop-out is waiting for it. */ + handOver(payload: ComposePopoutPayload): void; + /** Calls it off: the pop-out closes. */ + cancel(): void; +} + +/** + * Opens an empty pop-out composer that waits for its draft. Call it straight + * from the click: a browser only opens a window for a user action. Null when + * no window opened. + */ +export function openComposerWindow(localAccountId?: string | null): ComposerWindow | null { + const handoff: ComposeHandoff | null = createComposeHandoff(); + if (!handoff) return null; + const url = appPath('/popout/compose') + query({ handoff: handoff.id, account: localAccountId }); + const opened = openWindow(url, `bulwark-compose-${handoff.id}`, 'compose'); + if (!focused(opened)) { + handoff.cancel(); + return null; + } + return { handOver: handoff.complete, cancel: handoff.cancel }; +} diff --git a/lib/popout/support.ts b/lib/popout/support.ts new file mode 100644 index 000000000..75fc54a0f --- /dev/null +++ b/lib/popout/support.ts @@ -0,0 +1,27 @@ +/** + * Whether "Open in new window" is on offer: an installed app or the desktop + * app, on a device with windows. In a browser tab, a tab does the same job + * and the app behaves as it always has. + */ + +import { IS_LITE } from '@/lib/lite'; +import { isStandaloneDisplayMode } from '@/lib/standalone-display'; +import '@/lib/desktop/types'; + +export function hasDesktopWindows(): boolean { + return typeof window !== 'undefined' && !!window.bulwarkDesktop?.windows; +} + +/** Display mode is `browser` in Electron popups too, hence the bridge check. */ +export function popoutsAvailable(): boolean { + // Lite has no such route: a static export can't list every message id. + if (IS_LITE || typeof window === 'undefined') return false; + // Sharing the session and renewing tokens safely across windows need both. + if (typeof BroadcastChannel === 'undefined' || !navigator.locks) return false; + if (hasDesktopWindows()) return true; + if (!isStandaloneDisplayMode()) return false; + // A home-screen app on a phone or tablet has no second window: window.open + // leaves for the browser there. + if ((window.navigator as Navigator & { standalone?: boolean }).standalone === true) return false; + return typeof window.matchMedia === 'function' && window.matchMedia('(pointer: fine)').matches; +} diff --git a/lib/popout/window-role.ts b/lib/popout/window-role.ts new file mode 100644 index 000000000..87c8f8324 --- /dev/null +++ b/lib/popout/window-role.ts @@ -0,0 +1,57 @@ +/** + * Whether this window is a pop-out (`/[locale]/popout/...`): a single message + * or a composer in its own window, without the rail or the sidebar. + * + * A pop-out is a guest of the main window's session. It never evicts an + * account, never starts a token timer and doesn't write the account list: + * stores/auth-store.ts and stores/account-store.ts ask here. + */ + +import { withBasePath } from '@/lib/browser-navigation'; + +/** First path segments that are pages of their own, so a files path like /files/popout/compose isn't taken for a pop-out. */ +const OTHER_SECTIONS = new Set(['mail', 'calendar', 'contacts', 'files', 'settings', 'apps', 'pro', 'admin', 'protocol', 'setup', 'connector', 'login', 'auth', 'plugins']); + +export function isPopoutPath(pathname: string): boolean { + const segments = pathname.split('/').filter(Boolean); + const at = segments.indexOf('popout'); + if (at < 0 || segments.slice(0, at).some((segment) => OTHER_SECTIONS.has(segment))) return false; + const rest = segments.slice(at + 1); + return (rest.length === 1 && rest[0] === 'compose') || (rest.length === 2 && rest[0] === 'message'); +} + +export function isPopoutWindow(): boolean { + return typeof window !== 'undefined' && isPopoutPath(window.location.pathname); +} + +/** + * localStorage for the stores a pop-out shares with the main window, without + * its writes: a pop-out on account B would otherwise switch the main window + * to B at its next reload, and write back a list older than the main + * window's. Throws on the server like a bare `localStorage` does, which + * createJSONStorage takes as "no storage". + */ +export function mainWindowStorage(): Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> { + if (typeof localStorage === 'undefined') throw new Error('No localStorage here'); + return { + getItem: (key) => localStorage.getItem(key), + setItem: (key, value) => { + if (!isPopoutWindow()) localStorage.setItem(key, value); + }, + removeItem: (key) => { + if (!isPopoutWindow()) localStorage.removeItem(key); + }, + }; +} + +/** + * Closes the pop-out. A script may only close a window a script opened, so a + * pop-out address opened by hand goes to the app instead. + */ +export function closePopoutWindow(): void { + if (typeof window === 'undefined') return; + window.close(); + window.setTimeout(() => { + if (!window.closed) window.location.replace(withBasePath('/')); + }, 300); +} diff --git a/lib/window-controls-overlay.ts b/lib/window-controls-overlay.ts new file mode 100644 index 000000000..481e2e2b1 --- /dev/null +++ b/lib/window-controls-overlay.ts @@ -0,0 +1,68 @@ +/** + * Window Controls Overlay: an installed app (and the desktop app's + * titleBarOverlay window) draws the page up to the top of the window and lays + * the native minimise, maximise and close buttons over it. + * + * Asks navigator.windowControlsOverlay, never the media query: + * `(display-mode: window-controls-overlay)` is false in Electron with + * titleBarOverlay, while the API and env(titlebar-area-*) work there. + * + * app/globals.css gives --titlebar-inset its first value from + * env(titlebar-area-height, 0px), so the page has the right top padding from + * the first paint, before any script runs. This module keeps the variable in + * step with the API afterwards, which is what a browser or bridge without the + * env() values relies on. + */ + +export interface TitlebarArea { + x: number; + y: number; + width: number; + height: number; +} + +interface OverlayLike extends EventTarget { + visible: boolean; + getTitlebarAreaRect?: () => { x: number; y: number; width: number; height: number }; +} + +export const TITLEBAR_INSET_VAR = '--titlebar-inset'; + +function overlay(): OverlayLike | null { + if (typeof navigator === 'undefined') return null; + const value = (navigator as Navigator & { windowControlsOverlay?: OverlayLike }).windowControlsOverlay; + return value ?? null; +} + +/** The strip the page may draw in next to the window's controls, or null when there is no overlay. */ +export function readTitlebarArea(): TitlebarArea | null { + const current = overlay(); + if (!current?.visible) return null; + const rect = current.getTitlebarAreaRect?.(); + if (!rect || !(rect.height > 0)) return null; + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; +} + +/** + * Sets --titlebar-inset for this area. With no area the inline value goes, so + * the stylesheet's value (0 without an overlay) is what counts again. + */ +export function applyTitlebarInset(area: TitlebarArea | null, root: HTMLElement = document.documentElement): void { + if (area) root.style.setProperty(TITLEBAR_INSET_VAR, `${area.height}px`); + else root.style.removeProperty(TITLEBAR_INSET_VAR); +} + +/** + * Calls `listener` now and whenever the overlay's size or visibility changes + * (`geometrychange`). Without the API it calls it once with null and listens + * to nothing, so a browser without it runs no extra code. Returns the + * unsubscribe. + */ +export function watchTitlebarArea(listener: (area: TitlebarArea | null) => void): () => void { + listener(readTitlebarArea()); + const current = overlay(); + if (!current) return () => {}; + const onChange = () => listener(readTitlebarArea()); + current.addEventListener('geometrychange', onChange); + return () => current.removeEventListener('geometrychange', onChange); +} diff --git a/locales/ar/common.json b/locales/ar/common.json index cc8420d14..fe781a228 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6451,5 +6451,22 @@ "next_issue": "الخطأ الإملائي أو النحوي التالي", "previous_issue": "الخطأ الإملائي أو النحوي السابق" } + }, + "pwaWindows": { + "open_in_window": "فتح في نافذة جديدة", + "save_all_to_folder": "حفظ الكل في مجلد…", + "saved_all": "{count, plural, zero {لم يُحفظ أي مرفق في {folder}.} one {تم حفظ مرفق واحد في {folder}.} two {تم حفظ مرفقين في {folder}.} few {تم حفظ # مرفقات في {folder}.} many {تم حفظ # مرفقًا في {folder}.} other {تم حفظ # مرفق في {folder}.}}", + "saved_partial": "تم حفظ {saved} من أصل {total} مرفقات في {folder}.", + "save_failed": "تعذر حفظ المرفقات في {folder}.", + "popup_blocked": "حظر المتصفح النافذة الجديدة. اسمح بالنوافذ المنبثقة لهذا الموقع وحاول مرة أخرى.", + "opening": "جارٍ الفتح…", + "session_ended_title": "تم تسجيل خروجك في هذه النافذة", + "session_ended_body": "افتح التطبيق وسجّل الدخول، ثم افتح هذه النافذة مرة أخرى.", + "open_app": "فتح التطبيق", + "close_window": "إغلاق النافذة", + "message_gone_title": "لم تعد هذه الرسالة موجودة", + "message_gone_body": "تم حذفها أو نقلها إلى حساب آخر.", + "message_moved": "تم نقل هذه الرسالة إلى مجلد آخر.", + "message_moved_to": "تم نقل هذه الرسالة إلى {folder}." } } diff --git a/locales/ca/common.json b/locales/ca/common.json index 6e15f53fc..e1f77dd7f 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6451,5 +6451,22 @@ "next_issue": "Error d'ortografia o de gramàtica següent", "previous_issue": "Error d'ortografia o de gramàtica anterior" } + }, + "pwaWindows": { + "open_in_window": "Obre en una finestra nova", + "save_all_to_folder": "Desa-ho tot en una carpeta…", + "saved_all": "{count, plural, one {S’ha desat # adjunt a {folder}.} other {S’han desat # adjunts a {folder}.}}", + "saved_partial": "S’han desat {saved} de {total} adjunts a {folder}.", + "save_failed": "No s’han pogut desar els adjunts a {folder}.", + "popup_blocked": "El navegador ha blocat la finestra nova. Permet les finestres emergents per a aquest lloc i torna-ho a provar.", + "opening": "S’està obrint…", + "session_ended_title": "Has tancat la sessió en aquesta finestra", + "session_ended_body": "Obre l’aplicació i inicia la sessió, i després torna a obrir aquesta finestra.", + "open_app": "Obre l’aplicació", + "close_window": "Tanca la finestra", + "message_gone_title": "Aquest missatge ja no hi és", + "message_gone_body": "S’ha suprimit o s’ha mogut a un altre compte.", + "message_moved": "Aquest missatge s’ha mogut a una altra carpeta.", + "message_moved_to": "Aquest missatge s’ha mogut a {folder}." } } diff --git a/locales/cs/common.json b/locales/cs/common.json index d8426dce2..b657149fc 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6451,5 +6451,22 @@ "next_issue": "Další pravopisná nebo gramatická chyba", "previous_issue": "Předchozí pravopisná nebo gramatická chyba" } + }, + "pwaWindows": { + "open_in_window": "Otevřít v novém okně", + "save_all_to_folder": "Uložit vše do složky…", + "saved_all": "{count, plural, one {# příloha uložena do {folder}.} few {# přílohy uloženy do {folder}.} other {# příloh uloženo do {folder}.}}", + "saved_partial": "Uloženo {saved} z {total} příloh do {folder}.", + "save_failed": "Přílohy se nepodařilo uložit do {folder}.", + "popup_blocked": "Prohlížeč nové okno zablokoval. Povolte vyskakovací okna pro tento web a zkuste to znovu.", + "opening": "Otevírání…", + "session_ended_title": "V tomto okně jste odhlášeni", + "session_ended_body": "Otevřete aplikaci, přihlaste se a potom toto okno otevřete znovu.", + "open_app": "Otevřít aplikaci", + "close_window": "Zavřít okno", + "message_gone_title": "Tato zpráva už tu není", + "message_gone_body": "Byla smazána nebo přesunuta do jiného účtu.", + "message_moved": "Tato zpráva byla přesunuta do jiné složky.", + "message_moved_to": "Tato zpráva byla přesunuta do {folder}." } } diff --git a/locales/da/common.json b/locales/da/common.json index cf66a7294..2cf84faeb 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6451,5 +6451,22 @@ "next_issue": "Næste stave- eller grammatikfejl", "previous_issue": "Forrige stave- eller grammatikfejl" } + }, + "pwaWindows": { + "open_in_window": "Åbn i nyt vindue", + "save_all_to_folder": "Gem alle i en mappe…", + "saved_all": "{count, plural, one {# vedhæftet fil gemt i {folder}.} other {# vedhæftede filer gemt i {folder}.}}", + "saved_partial": "{saved} af {total} vedhæftede filer gemt i {folder}.", + "save_failed": "De vedhæftede filer kunne ikke gemmes i {folder}.", + "popup_blocked": "Browseren blokerede det nye vindue. Tillad pop op-vinduer for dette websted, og prøv igen.", + "opening": "Åbner…", + "session_ended_title": "Du er logget ud i dette vindue", + "session_ended_body": "Åbn appen og log ind, og åbn derefter dette vindue igen.", + "open_app": "Åbn appen", + "close_window": "Luk vindue", + "message_gone_title": "Denne besked er væk", + "message_gone_body": "Den er slettet eller flyttet til en anden konto.", + "message_moved": "Denne besked er flyttet til en anden mappe.", + "message_moved_to": "Denne besked er flyttet til {folder}." } } diff --git a/locales/de/common.json b/locales/de/common.json index 3772dbab8..3528ade90 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6451,5 +6451,22 @@ "next_issue": "Nächster Rechtschreib- oder Grammatikfehler", "previous_issue": "Vorheriger Rechtschreib- oder Grammatikfehler" } + }, + "pwaWindows": { + "open_in_window": "In neuem Fenster öffnen", + "save_all_to_folder": "Alle in einem Ordner speichern…", + "saved_all": "{count, plural, one {# Anhang in {folder} gespeichert.} other {# Anhänge in {folder} gespeichert.}}", + "saved_partial": "{saved} von {total} Anhängen in {folder} gespeichert.", + "save_failed": "Die Anhänge konnten nicht in {folder} gespeichert werden.", + "popup_blocked": "Der Browser hat das neue Fenster blockiert. Erlauben Sie Pop-ups für diese Seite und versuchen Sie es noch einmal.", + "opening": "Wird geöffnet…", + "session_ended_title": "Sie sind in diesem Fenster abgemeldet", + "session_ended_body": "Öffnen Sie die App, melden Sie sich an und öffnen Sie dieses Fenster dann noch einmal.", + "open_app": "App öffnen", + "close_window": "Fenster schließen", + "message_gone_title": "Diese Nachricht ist nicht mehr da", + "message_gone_body": "Sie wurde gelöscht oder in ein anderes Konto verschoben.", + "message_moved": "Diese Nachricht wurde in einen anderen Ordner verschoben.", + "message_moved_to": "Diese Nachricht wurde nach {folder} verschoben." } } diff --git a/locales/en/common.json b/locales/en/common.json index 57a58dab4..d27ee973f 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6451,5 +6451,22 @@ "next_issue": "Next spelling or grammar issue", "previous_issue": "Previous spelling or grammar issue" } + }, + "pwaWindows": { + "open_in_window": "Open in new window", + "save_all_to_folder": "Save all to a folder…", + "saved_all": "{count, plural, one {Saved # attachment to {folder}.} other {Saved # attachments to {folder}.}}", + "saved_partial": "Saved {saved} of {total} attachments to {folder}.", + "save_failed": "Could not save the attachments to {folder}.", + "popup_blocked": "The browser blocked the new window. Allow pop-ups for this site and try again.", + "opening": "Opening…", + "session_ended_title": "You are signed out in this window", + "session_ended_body": "Open the app and sign in, then open this window again.", + "open_app": "Open the app", + "close_window": "Close window", + "message_gone_title": "This message is gone", + "message_gone_body": "It was deleted or moved to another account.", + "message_moved": "This message was moved to another folder.", + "message_moved_to": "This message was moved to {folder}." } } diff --git a/locales/es/common.json b/locales/es/common.json index cc786a268..2c1959490 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6451,5 +6451,22 @@ "next_issue": "Siguiente error de ortografía o gramática", "previous_issue": "Anterior error de ortografía o gramática" } + }, + "pwaWindows": { + "open_in_window": "Abrir en una ventana nueva", + "save_all_to_folder": "Guardar todo en una carpeta…", + "saved_all": "{count, plural, one {Se guardó # adjunto en {folder}.} other {Se guardaron # adjuntos en {folder}.}}", + "saved_partial": "Se guardaron {saved} de {total} adjuntos en {folder}.", + "save_failed": "No se pudieron guardar los adjuntos en {folder}.", + "popup_blocked": "El navegador bloqueó la ventana nueva. Permite las ventanas emergentes para este sitio e inténtalo de nuevo.", + "opening": "Abriendo…", + "session_ended_title": "Has cerrado sesión en esta ventana", + "session_ended_body": "Abre la aplicación e inicia sesión, y luego vuelve a abrir esta ventana.", + "open_app": "Abrir la aplicación", + "close_window": "Cerrar ventana", + "message_gone_title": "Este mensaje ya no está", + "message_gone_body": "Se eliminó o se movió a otra cuenta.", + "message_moved": "Este mensaje se movió a otra carpeta.", + "message_moved_to": "Este mensaje se movió a {folder}." } } diff --git a/locales/et/common.json b/locales/et/common.json index 2743f7f83..b3bd5282f 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6451,5 +6451,22 @@ "next_issue": "Järgmine õigekirja- või grammatikaviga", "previous_issue": "Eelmine õigekirja- või grammatikaviga" } + }, + "pwaWindows": { + "open_in_window": "Ava uues aknas", + "save_all_to_folder": "Salvesta kõik kausta…", + "saved_all": "{count, plural, one {# manus salvestati kausta {folder}.} other {# manust salvestati kausta {folder}.}}", + "saved_partial": "{total} manusest {saved} salvestati kausta {folder}.", + "save_failed": "Manuseid ei saanud kausta {folder} salvestada.", + "popup_blocked": "Brauser blokeeris uue akna. Luba selle saidi hüpikaknad ja proovi uuesti.", + "opening": "Avamine…", + "session_ended_title": "Oled selles aknas välja logitud", + "session_ended_body": "Ava rakendus ja logi sisse, seejärel ava see aken uuesti.", + "open_app": "Ava rakendus", + "close_window": "Sulge aken", + "message_gone_title": "Seda kirja enam ei ole", + "message_gone_body": "See kustutati või teisaldati teisele kontole.", + "message_moved": "See kiri teisaldati teise kausta.", + "message_moved_to": "See kiri teisaldati kausta {folder}." } } diff --git a/locales/fa/common.json b/locales/fa/common.json index 539f81c1a..e7534be79 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6451,5 +6451,22 @@ "next_issue": "خطای املایی یا دستوری بعدی", "previous_issue": "خطای املایی یا دستوری قبلی" } + }, + "pwaWindows": { + "open_in_window": "باز کردن در پنجرهٔ جدید", + "save_all_to_folder": "ذخیرهٔ همه در یک پوشه…", + "saved_all": "{count, plural, one {# پیوست در {folder} ذخیره شد.} other {# پیوست در {folder} ذخیره شد.}}", + "saved_partial": "{saved} از {total} پیوست در {folder} ذخیره شد.", + "save_failed": "پیوست‌ها در {folder} ذخیره نشدند.", + "popup_blocked": "مرورگر پنجرهٔ جدید را مسدود کرد. پنجره‌های بازشو را برای این سایت مجاز کنید و دوباره امتحان کنید.", + "opening": "در حال باز کردن…", + "session_ended_title": "در این پنجره از حساب خارج شده‌اید", + "session_ended_body": "برنامه را باز کنید و وارد شوید، سپس این پنجره را دوباره باز کنید.", + "open_app": "باز کردن برنامه", + "close_window": "بستن پنجره", + "message_gone_title": "این پیام دیگر وجود ندارد", + "message_gone_body": "حذف شده یا به حساب دیگری منتقل شده است.", + "message_moved": "این پیام به پوشهٔ دیگری منتقل شد.", + "message_moved_to": "این پیام به {folder} منتقل شد." } } diff --git a/locales/fr/common.json b/locales/fr/common.json index fb7d148ec..04d52aad6 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6451,5 +6451,22 @@ "next_issue": "Faute d'orthographe ou de grammaire suivante", "previous_issue": "Faute d'orthographe ou de grammaire précédente" } + }, + "pwaWindows": { + "open_in_window": "Ouvrir dans une nouvelle fenêtre", + "save_all_to_folder": "Tout enregistrer dans un dossier…", + "saved_all": "{count, plural, one {# pièce jointe enregistrée dans {folder}.} other {# pièces jointes enregistrées dans {folder}.}}", + "saved_partial": "{saved} pièces jointes sur {total} enregistrées dans {folder}.", + "save_failed": "Impossible d’enregistrer les pièces jointes dans {folder}.", + "popup_blocked": "Le navigateur a bloqué la nouvelle fenêtre. Autorisez les fenêtres pop-up pour ce site et réessayez.", + "opening": "Ouverture…", + "session_ended_title": "Vous êtes déconnecté dans cette fenêtre", + "session_ended_body": "Ouvrez l’application et connectez-vous, puis rouvrez cette fenêtre.", + "open_app": "Ouvrir l’application", + "close_window": "Fermer la fenêtre", + "message_gone_title": "Ce message n’existe plus", + "message_gone_body": "Il a été supprimé ou déplacé vers un autre compte.", + "message_moved": "Ce message a été déplacé vers un autre dossier.", + "message_moved_to": "Ce message a été déplacé vers {folder}." } } diff --git a/locales/he/common.json b/locales/he/common.json index 73d75426a..e44b1ebe3 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6452,5 +6452,22 @@ "next_issue": "שגיאת האיות או הדקדוק הבאה", "previous_issue": "שגיאת האיות או הדקדוק הקודמת" } + }, + "pwaWindows": { + "open_in_window": "פתיחה בחלון חדש", + "save_all_to_folder": "שמירת הכול בתיקייה…", + "saved_all": "{count, plural, one {קובץ מצורף אחד נשמר ב-{folder}.} two {# קבצים מצורפים נשמרו ב-{folder}.} other {# קבצים מצורפים נשמרו ב-{folder}.}}", + "saved_partial": "{saved} מתוך {total} קבצים מצורפים נשמרו ב-{folder}.", + "save_failed": "לא ניתן לשמור את הקבצים המצורפים ב-{folder}.", + "popup_blocked": "הדפדפן חסם את החלון החדש. יש לאפשר חלונות קופצים לאתר הזה ולנסות שוב.", + "opening": "בפתיחה…", + "session_ended_title": "התנתקת בחלון הזה", + "session_ended_body": "יש לפתוח את האפליקציה ולהתחבר, ואז לפתוח את החלון הזה שוב.", + "open_app": "פתיחת האפליקציה", + "close_window": "סגירת החלון", + "message_gone_title": "ההודעה הזו כבר לא קיימת", + "message_gone_body": "היא נמחקה או הועברה לחשבון אחר.", + "message_moved": "ההודעה הזו הועברה לתיקייה אחרת.", + "message_moved_to": "ההודעה הזו הועברה אל {folder}." } } diff --git a/locales/hu/common.json b/locales/hu/common.json index b45fe495e..d957c82c1 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6451,5 +6451,22 @@ "next_issue": "Következő helyesírási vagy nyelvtani hiba", "previous_issue": "Előző helyesírási vagy nyelvtani hiba" } + }, + "pwaWindows": { + "open_in_window": "Megnyitás új ablakban", + "save_all_to_folder": "Összes mentése mappába…", + "saved_all": "{count, plural, one {# melléklet mentve ide: {folder}.} other {# melléklet mentve ide: {folder}.}}", + "saved_partial": "{total} mellékletből {saved} mentve ide: {folder}.", + "save_failed": "A mellékleteket nem sikerült ide menteni: {folder}.", + "popup_blocked": "A böngésző letiltotta az új ablakot. Engedélyezze az előugró ablakokat ennél a webhelynél, majd próbálja újra.", + "opening": "Megnyitás…", + "session_ended_title": "Ebben az ablakban ki van jelentkezve", + "session_ended_body": "Nyissa meg az alkalmazást, jelentkezzen be, majd nyissa meg újra ezt az ablakot.", + "open_app": "Alkalmazás megnyitása", + "close_window": "Ablak bezárása", + "message_gone_title": "Ez az üzenet már nincs meg", + "message_gone_body": "Törölték, vagy másik fiókba helyezték át.", + "message_moved": "Ezt az üzenetet másik mappába helyezték át.", + "message_moved_to": "Ezt az üzenetet ide helyezték át: {folder}." } } diff --git a/locales/it/common.json b/locales/it/common.json index a021b7397..ea5b317df 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6451,5 +6451,22 @@ "next_issue": "Errore di ortografia o grammatica successivo", "previous_issue": "Errore di ortografia o grammatica precedente" } + }, + "pwaWindows": { + "open_in_window": "Apri in una nuova finestra", + "save_all_to_folder": "Salva tutto in una cartella…", + "saved_all": "{count, plural, one {# allegato salvato in {folder}.} other {# allegati salvati in {folder}.}}", + "saved_partial": "{saved} allegati su {total} salvati in {folder}.", + "save_failed": "Impossibile salvare gli allegati in {folder}.", + "popup_blocked": "Il browser ha bloccato la nuova finestra. Consenti i pop-up per questo sito e riprova.", + "opening": "Apertura…", + "session_ended_title": "Hai effettuato la disconnessione in questa finestra", + "session_ended_body": "Apri l’app e accedi, poi riapri questa finestra.", + "open_app": "Apri l’app", + "close_window": "Chiudi finestra", + "message_gone_title": "Questo messaggio non c’è più", + "message_gone_body": "È stato eliminato o spostato in un altro account.", + "message_moved": "Questo messaggio è stato spostato in un’altra cartella.", + "message_moved_to": "Questo messaggio è stato spostato in {folder}." } } diff --git a/locales/ja/common.json b/locales/ja/common.json index f2ea41909..7be2a0971 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6451,5 +6451,22 @@ "next_issue": "次のスペルまたは文法の問題", "previous_issue": "前のスペルまたは文法の問題" } + }, + "pwaWindows": { + "open_in_window": "新しいウィンドウで開く", + "save_all_to_folder": "すべてをフォルダーに保存…", + "saved_all": "{count, plural, other {# 件の添付ファイルを {folder} に保存しました。}}", + "saved_partial": "{total} 件中 {saved} 件の添付ファイルを {folder} に保存しました。", + "save_failed": "添付ファイルを {folder} に保存できませんでした。", + "popup_blocked": "ブラウザーが新しいウィンドウをブロックしました。このサイトのポップアップを許可して、もう一度お試しください。", + "opening": "開いています…", + "session_ended_title": "このウィンドウではサインアウトしています", + "session_ended_body": "アプリを開いてサインインしてから、このウィンドウをもう一度開いてください。", + "open_app": "アプリを開く", + "close_window": "ウィンドウを閉じる", + "message_gone_title": "このメッセージはもうありません", + "message_gone_body": "削除されたか、別のアカウントに移動されました。", + "message_moved": "このメッセージは別のフォルダーに移動されました。", + "message_moved_to": "このメッセージは {folder} に移動されました。" } } diff --git a/locales/ko/common.json b/locales/ko/common.json index cbde91d32..e288ab8cd 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6451,5 +6451,22 @@ "next_issue": "다음 맞춤법 또는 문법 문제", "previous_issue": "이전 맞춤법 또는 문법 문제" } + }, + "pwaWindows": { + "open_in_window": "새 창에서 열기", + "save_all_to_folder": "모두 폴더에 저장…", + "saved_all": "{count, plural, other {첨부 파일 #개를 {folder}에 저장했습니다.}}", + "saved_partial": "첨부 파일 {total}개 중 {saved}개를 {folder}에 저장했습니다.", + "save_failed": "첨부 파일을 {folder}에 저장할 수 없습니다.", + "popup_blocked": "브라우저가 새 창을 차단했습니다. 이 사이트의 팝업을 허용한 후 다시 시도하세요.", + "opening": "여는 중…", + "session_ended_title": "이 창에서 로그아웃되었습니다", + "session_ended_body": "앱을 열어 로그인한 다음 이 창을 다시 여세요.", + "open_app": "앱 열기", + "close_window": "창 닫기", + "message_gone_title": "이 메시지는 더 이상 없습니다", + "message_gone_body": "삭제되었거나 다른 계정으로 이동되었습니다.", + "message_moved": "이 메시지는 다른 폴더로 이동되었습니다.", + "message_moved_to": "이 메시지는 {folder}(으)로 이동되었습니다." } } diff --git a/locales/lv/common.json b/locales/lv/common.json index 02d30c2f9..3caa2488c 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6451,5 +6451,22 @@ "next_issue": "Nākamā pareizrakstības vai gramatikas kļūda", "previous_issue": "Iepriekšējā pareizrakstības vai gramatikas kļūda" } + }, + "pwaWindows": { + "open_in_window": "Atvērt jaunā logā", + "save_all_to_folder": "Saglabāt visu mapē…", + "saved_all": "{count, plural, zero {# pielikumi saglabāti mapē {folder}.} one {# pielikums saglabāts mapē {folder}.} other {# pielikumi saglabāti mapē {folder}.}}", + "saved_partial": "Saglabāti {saved} no {total} pielikumiem mapē {folder}.", + "save_failed": "Pielikumus neizdevās saglabāt mapē {folder}.", + "popup_blocked": "Pārlūks bloķēja jauno logu. Atļaujiet uznirstošos logus šai vietnei un mēģiniet vēlreiz.", + "opening": "Notiek atvēršana…", + "session_ended_title": "Šajā logā esat izrakstījies", + "session_ended_body": "Atveriet lietotni un pierakstieties, pēc tam atveriet šo logu vēlreiz.", + "open_app": "Atvērt lietotni", + "close_window": "Aizvērt logu", + "message_gone_title": "Šī ziņojuma vairs nav", + "message_gone_body": "Tas tika dzēsts vai pārvietots uz citu kontu.", + "message_moved": "Šis ziņojums tika pārvietots uz citu mapi.", + "message_moved_to": "Šis ziņojums tika pārvietots uz {folder}." } } diff --git a/locales/mn/common.json b/locales/mn/common.json index 4dd86dea2..623ac15ad 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6451,5 +6451,22 @@ "next_issue": "Дараагийн зөв бичих эсвэл хэл зүйн алдаа", "previous_issue": "Өмнөх зөв бичих эсвэл хэл зүйн алдаа" } + }, + "pwaWindows": { + "open_in_window": "Шинэ цонхонд нээх", + "save_all_to_folder": "Бүгдийг хавтсанд хадгалах…", + "saved_all": "{count, plural, one {# хавсралтыг {folder} хавтсанд хадгаллаа.} other {# хавсралтыг {folder} хавтсанд хадгаллаа.}}", + "saved_partial": "{total} хавсралтаас {saved}-г {folder} хавтсанд хадгаллаа.", + "save_failed": "Хавсралтуудыг {folder} хавтсанд хадгалж чадсангүй.", + "popup_blocked": "Хөтөч шинэ цонхыг хаасан байна. Энэ сайтад гарч ирэх цонхыг зөвшөөрөөд дахин оролдоно уу.", + "opening": "Нээж байна…", + "session_ended_title": "Та энэ цонхонд гарсан байна", + "session_ended_body": "Аппыг нээж нэвтэрсний дараа энэ цонхыг дахин нээнэ үү.", + "open_app": "Аппыг нээх", + "close_window": "Цонхыг хаах", + "message_gone_title": "Энэ мессеж байхгүй болсон", + "message_gone_body": "Устгагдсан эсвэл өөр бүртгэл рүү зөөгдсөн.", + "message_moved": "Энэ мессежийг өөр хавтас руу зөөсөн.", + "message_moved_to": "Энэ мессежийг {folder} руу зөөсөн." } } diff --git a/locales/nb/common.json b/locales/nb/common.json index d98bd1756..9cb532938 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6451,5 +6451,22 @@ "next_issue": "Neste stave- eller grammatikkfeil", "previous_issue": "Forrige stave- eller grammatikkfeil" } + }, + "pwaWindows": { + "open_in_window": "Åpne i nytt vindu", + "save_all_to_folder": "Lagre alle i en mappe…", + "saved_all": "{count, plural, one {# vedlegg lagret i {folder}.} other {# vedlegg lagret i {folder}.}}", + "saved_partial": "{saved} av {total} vedlegg lagret i {folder}.", + "save_failed": "Kunne ikke lagre vedleggene i {folder}.", + "popup_blocked": "Nettleseren blokkerte det nye vinduet. Tillat popup-vinduer for dette nettstedet og prøv igjen.", + "opening": "Åpner…", + "session_ended_title": "Du er logget ut i dette vinduet", + "session_ended_body": "Åpne appen og logg inn, og åpne deretter dette vinduet på nytt.", + "open_app": "Åpne appen", + "close_window": "Lukk vindu", + "message_gone_title": "Denne meldingen er borte", + "message_gone_body": "Den ble slettet eller flyttet til en annen konto.", + "message_moved": "Denne meldingen ble flyttet til en annen mappe.", + "message_moved_to": "Denne meldingen ble flyttet til {folder}." } } diff --git a/locales/nl/common.json b/locales/nl/common.json index 4d541f358..80af5c4e4 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6451,5 +6451,22 @@ "next_issue": "Volgende spel- of grammaticafout", "previous_issue": "Vorige spel- of grammaticafout" } + }, + "pwaWindows": { + "open_in_window": "Openen in nieuw venster", + "save_all_to_folder": "Alles in een map opslaan…", + "saved_all": "{count, plural, one {# bijlage opgeslagen in {folder}.} other {# bijlagen opgeslagen in {folder}.}}", + "saved_partial": "{saved} van {total} bijlagen opgeslagen in {folder}.", + "save_failed": "De bijlagen konden niet in {folder} worden opgeslagen.", + "popup_blocked": "De browser heeft het nieuwe venster geblokkeerd. Sta pop-ups toe voor deze site en probeer het opnieuw.", + "opening": "Openen…", + "session_ended_title": "Je bent afgemeld in dit venster", + "session_ended_body": "Open de app en meld je aan, en open dit venster daarna opnieuw.", + "open_app": "App openen", + "close_window": "Venster sluiten", + "message_gone_title": "Dit bericht is er niet meer", + "message_gone_body": "Het is verwijderd of naar een ander account verplaatst.", + "message_moved": "Dit bericht is naar een andere map verplaatst.", + "message_moved_to": "Dit bericht is verplaatst naar {folder}." } } diff --git a/locales/pl/common.json b/locales/pl/common.json index a6a7cd520..60a53be78 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6451,5 +6451,22 @@ "next_issue": "Następny błąd pisowni lub gramatyki", "previous_issue": "Poprzedni błąd pisowni lub gramatyki" } + }, + "pwaWindows": { + "open_in_window": "Otwórz w nowym oknie", + "save_all_to_folder": "Zapisz wszystko w folderze…", + "saved_all": "{count, plural, one {Zapisano # załącznik w {folder}.} few {Zapisano # załączniki w {folder}.} other {Zapisano # załączników w {folder}.}}", + "saved_partial": "Zapisano {saved} z {total} załączników w {folder}.", + "save_failed": "Nie udało się zapisać załączników w {folder}.", + "popup_blocked": "Przeglądarka zablokowała nowe okno. Zezwól na wyskakujące okna dla tej strony i spróbuj ponownie.", + "opening": "Otwieranie…", + "session_ended_title": "Wylogowano w tym oknie", + "session_ended_body": "Otwórz aplikację i zaloguj się, a potem otwórz to okno ponownie.", + "open_app": "Otwórz aplikację", + "close_window": "Zamknij okno", + "message_gone_title": "Tej wiadomości już nie ma", + "message_gone_body": "Została usunięta lub przeniesiona na inne konto.", + "message_moved": "Ta wiadomość została przeniesiona do innego folderu.", + "message_moved_to": "Ta wiadomość została przeniesiona do {folder}." } } diff --git a/locales/pt/common.json b/locales/pt/common.json index 685a98aea..079316bd5 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6451,5 +6451,22 @@ "next_issue": "Próximo erro de ortografia ou gramática", "previous_issue": "Erro de ortografia ou gramática anterior" } + }, + "pwaWindows": { + "open_in_window": "Abrir em nova janela", + "save_all_to_folder": "Guardar tudo numa pasta…", + "saved_all": "{count, plural, one {# anexo guardado em {folder}.} other {# anexos guardados em {folder}.}}", + "saved_partial": "{saved} de {total} anexos guardados em {folder}.", + "save_failed": "Não foi possível guardar os anexos em {folder}.", + "popup_blocked": "O navegador bloqueou a nova janela. Permita pop-ups para este site e tente novamente.", + "opening": "A abrir…", + "session_ended_title": "Terminou a sessão nesta janela", + "session_ended_body": "Abra a aplicação e inicie sessão, depois abra esta janela novamente.", + "open_app": "Abrir a aplicação", + "close_window": "Fechar janela", + "message_gone_title": "Esta mensagem já não existe", + "message_gone_body": "Foi eliminada ou movida para outra conta.", + "message_moved": "Esta mensagem foi movida para outra pasta.", + "message_moved_to": "Esta mensagem foi movida para {folder}." } } diff --git a/locales/ro/common.json b/locales/ro/common.json index 45c3fefed..36be951f0 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6451,5 +6451,22 @@ "next_issue": "Următoarea greșeală de ortografie sau gramatică", "previous_issue": "Greșeala de ortografie sau gramatică anterioară" } + }, + "pwaWindows": { + "open_in_window": "Deschide într-o fereastră nouă", + "save_all_to_folder": "Salvează tot într-un dosar…", + "saved_all": "{count, plural, one {# atașament salvat în {folder}.} few {# atașamente salvate în {folder}.} other {# de atașamente salvate în {folder}.}}", + "saved_partial": "{saved} din {total} atașamente salvate în {folder}.", + "save_failed": "Atașamentele nu au putut fi salvate în {folder}.", + "popup_blocked": "Browserul a blocat fereastra nouă. Permite ferestrele pop-up pentru acest site și încearcă din nou.", + "opening": "Se deschide…", + "session_ended_title": "Ești deconectat în această fereastră", + "session_ended_body": "Deschide aplicația și autentifică-te, apoi deschide din nou această fereastră.", + "open_app": "Deschide aplicația", + "close_window": "Închide fereastra", + "message_gone_title": "Acest mesaj nu mai există", + "message_gone_body": "A fost șters sau mutat în alt cont.", + "message_moved": "Acest mesaj a fost mutat în alt dosar.", + "message_moved_to": "Acest mesaj a fost mutat în {folder}." } } diff --git a/locales/ru/common.json b/locales/ru/common.json index b40069b61..b4e299886 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6451,5 +6451,22 @@ "next_issue": "Следующая орфографическая или грамматическая ошибка", "previous_issue": "Предыдущая орфографическая или грамматическая ошибка" } + }, + "pwaWindows": { + "open_in_window": "Открыть в новом окне", + "save_all_to_folder": "Сохранить всё в папку…", + "saved_all": "{count, plural, one {# вложение сохранено в {folder}.} few {# вложения сохранено в {folder}.} many {# вложений сохранено в {folder}.} other {# вложения сохранено в {folder}.}}", + "saved_partial": "Сохранено {saved} из {total} вложений в {folder}.", + "save_failed": "Не удалось сохранить вложения в {folder}.", + "popup_blocked": "Браузер заблокировал новое окно. Разрешите всплывающие окна для этого сайта и повторите попытку.", + "opening": "Открытие…", + "session_ended_title": "В этом окне вы вышли из системы", + "session_ended_body": "Откройте приложение и войдите, затем откройте это окно снова.", + "open_app": "Открыть приложение", + "close_window": "Закрыть окно", + "message_gone_title": "Этого письма больше нет", + "message_gone_body": "Оно удалено или перемещено в другую учётную запись.", + "message_moved": "Это письмо перемещено в другую папку.", + "message_moved_to": "Это письмо перемещено в {folder}." } } diff --git a/locales/sk/common.json b/locales/sk/common.json index aa0304452..e2d4f3d6e 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6451,5 +6451,22 @@ "next_issue": "Ďalšia pravopisná alebo gramatická chyba", "previous_issue": "Predchádzajúca pravopisná alebo gramatická chyba" } + }, + "pwaWindows": { + "open_in_window": "Otvoriť v novom okne", + "save_all_to_folder": "Uložiť všetko do priečinka…", + "saved_all": "{count, plural, one {# príloha uložená do {folder}.} few {# prílohy uložené do {folder}.} other {# príloh uložených do {folder}.}}", + "saved_partial": "Uložených {saved} z {total} príloh do {folder}.", + "save_failed": "Prílohy sa nepodarilo uložiť do {folder}.", + "popup_blocked": "Prehliadač zablokoval nové okno. Povoľte kontextové okná pre túto stránku a skúste to znova.", + "opening": "Otvára sa…", + "session_ended_title": "V tomto okne ste odhlásení", + "session_ended_body": "Otvorte aplikáciu, prihláste sa a potom toto okno otvorte znova.", + "open_app": "Otvoriť aplikáciu", + "close_window": "Zavrieť okno", + "message_gone_title": "Táto správa tu už nie je", + "message_gone_body": "Bola odstránená alebo presunutá do iného účtu.", + "message_moved": "Táto správa bola presunutá do iného priečinka.", + "message_moved_to": "Táto správa bola presunutá do {folder}." } } diff --git a/locales/th/common.json b/locales/th/common.json index 4d2c52754..f19480b77 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6459,5 +6459,22 @@ "next_issue": "ปัญหาการสะกดหรือไวยากรณ์ถัดไป", "previous_issue": "ปัญหาการสะกดหรือไวยากรณ์ก่อนหน้า" } + }, + "pwaWindows": { + "open_in_window": "เปิดในหน้าต่างใหม่", + "save_all_to_folder": "บันทึกทั้งหมดลงในโฟลเดอร์…", + "saved_all": "{count, plural, other {บันทึกไฟล์แนบ # รายการลงใน {folder} แล้ว}}", + "saved_partial": "บันทึกไฟล์แนบ {saved} จาก {total} รายการลงใน {folder} แล้ว", + "save_failed": "ไม่สามารถบันทึกไฟล์แนบลงใน {folder} ได้", + "popup_blocked": "เบราว์เซอร์บล็อกหน้าต่างใหม่ โปรดอนุญาตป๊อปอัปสำหรับเว็บไซต์นี้แล้วลองอีกครั้ง", + "opening": "กำลังเปิด…", + "session_ended_title": "คุณออกจากระบบในหน้าต่างนี้แล้ว", + "session_ended_body": "เปิดแอปและลงชื่อเข้าใช้ จากนั้นเปิดหน้าต่างนี้อีกครั้ง", + "open_app": "เปิดแอป", + "close_window": "ปิดหน้าต่าง", + "message_gone_title": "ไม่มีข้อความนี้แล้ว", + "message_gone_body": "ข้อความถูกลบหรือย้ายไปยังบัญชีอื่น", + "message_moved": "ข้อความนี้ถูกย้ายไปยังโฟลเดอร์อื่น", + "message_moved_to": "ข้อความนี้ถูกย้ายไปยัง {folder}" } } diff --git a/locales/tr/common.json b/locales/tr/common.json index 4f25997fa..cab9c409e 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6451,5 +6451,22 @@ "next_issue": "Sonraki yazım veya dil bilgisi hatası", "previous_issue": "Önceki yazım veya dil bilgisi hatası" } + }, + "pwaWindows": { + "open_in_window": "Yeni pencerede aç", + "save_all_to_folder": "Tümünü bir klasöre kaydet…", + "saved_all": "{count, plural, one {# ek {folder} klasörüne kaydedildi.} other {# ek {folder} klasörüne kaydedildi.}}", + "saved_partial": "{total} ekten {saved} tanesi {folder} klasörüne kaydedildi.", + "save_failed": "Ekler {folder} klasörüne kaydedilemedi.", + "popup_blocked": "Tarayıcı yeni pencereyi engelledi. Bu site için açılır pencerelere izin verip yeniden deneyin.", + "opening": "Açılıyor…", + "session_ended_title": "Bu pencerede oturumunuz kapalı", + "session_ended_body": "Uygulamayı açıp oturum açın, ardından bu pencereyi yeniden açın.", + "open_app": "Uygulamayı aç", + "close_window": "Pencereyi kapat", + "message_gone_title": "Bu ileti artık yok", + "message_gone_body": "Silindi veya başka bir hesaba taşındı.", + "message_moved": "Bu ileti başka bir klasöre taşındı.", + "message_moved_to": "Bu ileti {folder} klasörüne taşındı." } } diff --git a/locales/uk/common.json b/locales/uk/common.json index 408e920da..11b850991 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6451,5 +6451,22 @@ "next_issue": "Наступна орфографічна або граматична помилка", "previous_issue": "Попередня орфографічна або граматична помилка" } + }, + "pwaWindows": { + "open_in_window": "Відкрити в новому вікні", + "save_all_to_folder": "Зберегти все в папку…", + "saved_all": "{count, plural, one {# вкладення збережено в {folder}.} few {# вкладення збережено в {folder}.} many {# вкладень збережено в {folder}.} other {# вкладення збережено в {folder}.}}", + "saved_partial": "Збережено {saved} із {total} вкладень у {folder}.", + "save_failed": "Не вдалося зберегти вкладення в {folder}.", + "popup_blocked": "Браузер заблокував нове вікно. Дозвольте спливаючі вікна для цього сайту і спробуйте ще раз.", + "opening": "Відкриття…", + "session_ended_title": "У цьому вікні ви вийшли з облікового запису", + "session_ended_body": "Відкрийте застосунок і увійдіть, а потім відкрийте це вікно знову.", + "open_app": "Відкрити застосунок", + "close_window": "Закрити вікно", + "message_gone_title": "Цього листа більше немає", + "message_gone_body": "Його видалено або переміщено в інший обліковий запис.", + "message_moved": "Цього листа переміщено в іншу папку.", + "message_moved_to": "Цього листа переміщено в {folder}." } } diff --git a/locales/vi/common.json b/locales/vi/common.json index 6952aec2e..fe4c3bd54 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6463,5 +6463,22 @@ "next_issue": "Lỗi chính tả hoặc ngữ pháp tiếp theo", "previous_issue": "Lỗi chính tả hoặc ngữ pháp trước đó" } + }, + "pwaWindows": { + "open_in_window": "Mở trong cửa sổ mới", + "save_all_to_folder": "Lưu tất cả vào thư mục…", + "saved_all": "{count, plural, other {Đã lưu # tệp đính kèm vào {folder}.}}", + "saved_partial": "Đã lưu {saved} trong {total} tệp đính kèm vào {folder}.", + "save_failed": "Không thể lưu tệp đính kèm vào {folder}.", + "popup_blocked": "Trình duyệt đã chặn cửa sổ mới. Hãy cho phép cửa sổ bật lên với trang này rồi thử lại.", + "opening": "Đang mở…", + "session_ended_title": "Bạn đã đăng xuất trong cửa sổ này", + "session_ended_body": "Hãy mở ứng dụng và đăng nhập, rồi mở lại cửa sổ này.", + "open_app": "Mở ứng dụng", + "close_window": "Đóng cửa sổ", + "message_gone_title": "Thư này không còn nữa", + "message_gone_body": "Thư đã bị xóa hoặc chuyển sang tài khoản khác.", + "message_moved": "Thư này đã được chuyển sang thư mục khác.", + "message_moved_to": "Thư này đã được chuyển đến {folder}." } } diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 1236173cf..c1b1dbaa6 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6451,5 +6451,22 @@ "next_issue": "下一個拼字或文法問題", "previous_issue": "上一個拼字或文法問題" } + }, + "pwaWindows": { + "open_in_window": "在新視窗中開啟", + "save_all_to_folder": "全部儲存到資料夾…", + "saved_all": "{count, plural, other {已將 # 個附件儲存到 {folder}。}}", + "saved_partial": "已將 {total} 個附件中的 {saved} 個儲存到 {folder}。", + "save_failed": "無法將附件儲存到 {folder}。", + "popup_blocked": "瀏覽器封鎖了新視窗。請允許此網站顯示彈出式視窗,然後再試一次。", + "opening": "正在開啟…", + "session_ended_title": "你已在此視窗中登出", + "session_ended_body": "請開啟應用程式並登入,然後重新開啟此視窗。", + "open_app": "開啟應用程式", + "close_window": "關閉視窗", + "message_gone_title": "這封郵件已不存在", + "message_gone_body": "它已被刪除或移到其他帳戶。", + "message_moved": "這封郵件已移到其他資料夾。", + "message_moved_to": "這封郵件已移到 {folder}。" } } diff --git a/locales/zh/common.json b/locales/zh/common.json index f51d10c28..0b7890579 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6451,5 +6451,22 @@ "next_issue": "下一个拼写或语法问题", "previous_issue": "上一个拼写或语法问题" } + }, + "pwaWindows": { + "open_in_window": "在新窗口中打开", + "save_all_to_folder": "全部保存到文件夹…", + "saved_all": "{count, plural, other {已将 # 个附件保存到 {folder}。}}", + "saved_partial": "已将 {total} 个附件中的 {saved} 个保存到 {folder}。", + "save_failed": "无法将附件保存到 {folder}。", + "popup_blocked": "浏览器阻止了新窗口。请允许此网站显示弹出窗口,然后重试。", + "opening": "正在打开…", + "session_ended_title": "你已在此窗口中退出登录", + "session_ended_body": "请打开应用并登录,然后重新打开此窗口。", + "open_app": "打开应用", + "close_window": "关闭窗口", + "message_gone_title": "这封邮件已不存在", + "message_gone_body": "它已被删除或移动到其他账户。", + "message_moved": "这封邮件已移动到其他文件夹。", + "message_moved_to": "这封邮件已移动到 {folder}。" } } diff --git a/public/sw.js b/public/sw.js index 3dc7ff7b8..a5552b96d 100644 --- a/public/sw.js +++ b/public/sw.js @@ -108,7 +108,7 @@ if (typeof caches !== "undefined" && caches && typeof caches.open === "function" readOfflineShell(); } -const APP_SECTIONS = new Set(["", "mail", "settings", "calendar", "contacts", "files", "pro", "apps"]); +const APP_SECTIONS = new Set(["", "mail", "settings", "calendar", "contacts", "files", "pro", "apps", "popout"]); /** The page path inside the app ("/mail/inbox"), or null for anything that isn't a page of the app. */ function appPagePath(url) { @@ -138,7 +138,12 @@ function documentKey(url) { async function cachedDocument(url) { const path = appPagePath(url) || "/"; const section = "/" + (path.split("/").filter(Boolean)[0] || ""); - const candidates = [documentKey(url), self.location.origin + BASE_PATH + (section === "/" ? "" : section), self.location.origin + BASE_PATH]; + const appRoot = self.location.origin + BASE_PATH; + // A pop-out has no section page, and the start page would put the whole app + // into its window. Its own kind of page reads the id from the address bar. + const candidates = section === "/popout" + ? [documentKey(url), appRoot + (path.startsWith("/popout/message/") ? "/popout/message/_" : "/popout/compose")] + : [documentKey(url), appRoot + (section === "/" ? "" : section), appRoot]; // Only looked into when it exists: opening it would create it again // after the next build's worker deleted it. if (await caches.has(SHELL_CACHE)) { diff --git a/scripts/lite/lib.mjs b/scripts/lite/lib.mjs index 559c09eca..f2dee815e 100644 --- a/scripts/lite/lib.mjs +++ b/scripts/lite/lib.mjs @@ -41,6 +41,7 @@ export const LITE_REMOVED_PATHS = [ "app/(main)/[...rest]", "app/(main)/[locale]/[...rest]", "app/(main)/protocol", + "app/(main)/[locale]/popout", "e2e", "integration", ]; diff --git a/stores/__tests__/auth-store-shared-session.test.ts b/stores/__tests__/auth-store-shared-session.test.ts new file mode 100644 index 000000000..19dcb2cbb --- /dev/null +++ b/stores/__tests__/auth-store-shared-session.test.ts @@ -0,0 +1,263 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel'; + +// A pop-out starts on the main window's session. It must not evict an account +// (a login without "remember me" has nothing to restore, which checkAuth +// answers by removing it from the list every window shares), and must not +// refresh on its own: without a cross-window lock a stale refresh can end in +// `Replay` on Stalwart 1.0, which revokes the grant. + +// Going on without a main window waits out its answer, 3 s on a timer that a busy machine stretches. +vi.setConfig({ testTimeout: 30_000 }); + +const made: Array<{ header: string; refresh?: () => Promise<string | null> }> = []; + +vi.mock('@/lib/jmap/client', () => { + class JMAPClient { + authHeader = ''; + onTokenRefresh?: () => Promise<string | null>; + constructor(public serverUrl: string, public username: string, public password: string) { + this.authHeader = `Basic ${password}`; + } + static withBearer(serverUrl: string, token: string, username: string, refresh?: () => Promise<string | null>) { + const client = new JMAPClient(serverUrl, username, ''); + client.authHeader = `Bearer ${token}`; + client.onTokenRefresh = refresh; + made.push({ header: client.authHeader, refresh }); + return client; + } + static withSharedAuth(serverUrl: string, username: string, header: string, refresh?: () => Promise<string | null>) { + const client = new JMAPClient(serverUrl, username, ''); + client.authHeader = header; + client.onTokenRefresh = refresh; + made.push({ header, refresh }); + return client; + } + async connect() {} + getSessionUsername() { return this.username; } + getUsername() { return this.username; } + async getIdentities() { return [{ id: 'i', email: this.username, name: this.username, mayDelete: false }]; } + getAuthHeader() { return this.authHeader; } + updateAccessToken(token: string) { this.authHeader = `Bearer ${token}`; } + onConnectionChange() {} + onRateLimit() {} + getRateLimitRemainingMs() { return 0; } + supportsContacts() { return false; } + supportsPrincipals() { return false; } + supportsVacationResponse() { return false; } + supportsCalendars() { return false; } + supportsSieve() { return false; } + disconnect() {} + getAccountId() { return 'acct'; } + isOfflineOnly() { return false; } + } + class RateLimitError extends Error {} + return { JMAPClient, RateLimitError }; +}); +vi.mock('@/lib/stalwart/principal', () => ({ fetchPrincipalDisplayName: async () => null })); + +import { openPopoutChannel, type PopoutChannel } from '@/lib/popout/channel'; +import { useAccountStore } from '@/stores/account-store'; +import { SHARED_SESSION_ENDED_EVENT, useAuthStore } from '@/stores/auth-store'; + +const account = (id: string, slot: number, over: Record<string, unknown> = {}) => ({ + id, label: id, serverUrl: 'https://mail.example.test', username: `${id}@example.test`, authMode: 'oauth' as const, + rememberMe: true, cookieSlot: slot, displayName: id, email: `${id}@example.test`, isConnected: false, hasError: false, + lastLoginAt: 1, avatarColor: '#000', isDefault: slot === 0, ...over, +}); + +let fetched: Array<{ url: string; method?: string }>; +let tokenAnswers: Response[]; +let main: PopoutChannel | null; +let requests: Array<{ accountId?: string; rejectedAuthHeader?: string }>; + +function startMainWindow(answer: (request: { accountId?: string; rejectedAuthHeader?: string }) => { activeAccountId: string; sessions: unknown[] } | null) { + main = openPopoutChannel((message) => { + if (message.type !== 'session-request') return; + requests.push({ accountId: message.accountId, rejectedAuthHeader: message.rejectedAuthHeader }); + const result = answer(message); + if (result) main?.post({ type: 'session-answer', requestId: message.requestId, answer: result as never }); + }); +} + +const session = (id: string, authHeader = 'Bearer main-token', authMode = 'oauth') => ({ + accountId: id, serverUrl: 'https://mail.example.test', username: `${id}@example.test`, authMode, authHeader, +}); + +const tokenCalls = () => fetched.filter((call) => call.url.includes('/api/auth/token')); +const sessionCalls = () => fetched.filter((call) => call.url.includes('/api/auth/session')); + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'], shouldAdvanceTime: true, advanceTimeDelta: 50 }); + FakeBroadcastChannel.reset(); + vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); + window.history.replaceState(null, '', '/en/popout/message/abc'); + made.length = 0; + fetched = []; + tokenAnswers = []; + requests = []; + main = null; + useAccountStore.setState({ accounts: [account('alice', 0)], activeAccountId: 'alice', defaultAccountId: 'alice' }); + useAuthStore.setState({ isAuthenticated: false, client: null, activeAccountId: null }); + vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => { + fetched.push({ url: String(url), method: init?.method }); + if (String(url).includes('/api/auth/token')) { + return tokenAnswers.shift() ?? new Response(JSON.stringify({ access_token: 'cookie-token', expires_in: 3600 }), { status: 200 }); + } + return new Response('{}', { status: 200 }); + })); +}); + +afterEach(() => { + main?.close(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + window.history.replaceState(null, '', '/'); + localStorage.clear(); +}); + +describe('startSharedSession', () => { + it('uses the main window\'s session and asks no auth route for anything', async () => { + startMainWindow(() => ({ activeAccountId: 'alice', sessions: [session('alice')] })); + await expect(useAuthStore.getState().startSharedSession()).resolves.toBe('ready'); + const state = useAuthStore.getState(); + expect(state.isAuthenticated).toBe(true); + expect(state.activeAccountId).toBe('alice'); + expect(state.client?.getAuthHeader()).toBe('Bearer main-token'); + expect(tokenCalls()).toEqual([]); + expect(sessionCalls()).toEqual([]); + }); + + it('takes a login without "remember me" from the main window too', async () => { + useAccountStore.setState({ accounts: [account('alice', 0, { rememberMe: false, authMode: 'basic' })] }); + startMainWindow(() => ({ activeAccountId: 'alice', sessions: [session('alice', 'Basic abc', 'basic')] })); + await expect(useAuthStore.getState().startSharedSession()).resolves.toBe('ready'); + expect(useAuthStore.getState().client?.getAuthHeader()).toBe('Basic abc'); + }); + + it('opens the login it was asked for, whatever the main window has active', async () => { + useAccountStore.setState({ accounts: [account('alice', 0), account('bob', 1)] }); + startMainWindow(() => ({ activeAccountId: 'alice', sessions: [session('alice'), session('bob', 'Bearer bob-token')] })); + await useAuthStore.getState().startSharedSession({ accountId: 'bob' }); + expect(useAuthStore.getState().activeAccountId).toBe('bob'); + expect(useAuthStore.getState().client?.getAuthHeader()).toBe('Bearer bob-token'); + }); + + it('never evicts: a login without "remember me" and no main window stays in the list', async () => { + useAccountStore.setState({ accounts: [account('alice', 0, { rememberMe: false, authMode: 'basic' })] }); + await expect(useAuthStore.getState().startSharedSession()).resolves.toBe('no-session'); + expect(useAccountStore.getState().accounts.map((a) => a.id)).toEqual(['alice']); + expect(sessionCalls().filter((call) => call.method === 'DELETE')).toEqual([]); + expect(tokenCalls().filter((call) => call.method === 'DELETE')).toEqual([]); + }); + + it('never evicts on a refused cookie either', async () => { + tokenAnswers.push(new Response('{}', { status: 401 })); + await expect(useAuthStore.getState().startSharedSession()).resolves.toBe('no-session'); + expect(useAccountStore.getState().accounts.map((a) => a.id)).toEqual(['alice']); + }); + + it('restores from the cookies when no main window is open, without forcing a renewal', async () => { + await expect(useAuthStore.getState().startSharedSession()).resolves.toBe('ready'); + expect(useAuthStore.getState().client?.getAuthHeader()).toBe('Bearer cookie-token'); + const [call] = tokenCalls(); + expect(call.url).not.toContain('force=true'); + }); + + it('does not write the list or the active account that the main window owns', async () => { + useAccountStore.setState({ accounts: [account('alice', 0), account('bob', 1)], activeAccountId: 'alice' }); + localStorage.setItem('account-registry', 'written by the main window'); + localStorage.setItem('auth-storage', 'written by the main window'); + startMainWindow(() => ({ activeAccountId: 'alice', sessions: [session('bob', 'Bearer bob-token')] })); + await useAuthStore.getState().startSharedSession({ accountId: 'bob' }); + expect(localStorage.getItem('account-registry')).toBe('written by the main window'); + expect(localStorage.getItem('auth-storage')).toBe('written by the main window'); + }); + + it('starts no refresh timer', async () => { + startMainWindow(() => ({ activeAccountId: 'alice', sessions: [session('alice')] })); + await useAuthStore.getState().startSharedSession(); + await vi.advanceTimersByTimeAsync(7_200_000); + expect(tokenCalls()).toEqual([]); + expect(requests).toHaveLength(1); + }); +}); + +describe('a token the server refused in a pop-out', () => { + async function started() { + startMainWindow((request) => { + const header = request.rejectedAuthHeader ? 'Bearer renewed-by-main' : 'Bearer main-token'; + return { activeAccountId: 'alice', sessions: [session('alice', header)] }; + }); + await useAuthStore.getState().startSharedSession(); + return made[made.length - 1].refresh!; + } + + it('comes from the main window, which is told which one was refused', async () => { + const refresh = await started(); + await expect(refresh()).resolves.toBe('renewed-by-main'); + expect(requests[1]).toEqual({ accountId: 'alice', rejectedAuthHeader: 'Bearer main-token' }); + expect(tokenCalls()).toEqual([]); + }); + + it('gives up when the main window could not renew it, without signing anyone out', async () => { + startMainWindow(() => ({ activeAccountId: 'alice', sessions: [session('alice')] })); + await useAuthStore.getState().startSharedSession(); + const refresh = made[made.length - 1].refresh!; + await expect(refresh()).resolves.toBeNull(); + expect(tokenCalls()).toEqual([]); + expect(useAccountStore.getState().accounts).toHaveLength(1); + }); + + it('comes through the locked cookie path once no main window is left', async () => { + const refresh = await started(); + main?.close(); + main = null; + const locks = { request: vi.fn((_name: string, _options: object, callback: () => Promise<unknown>) => callback()) }; + Object.defineProperty(navigator, 'locks', { value: locks, configurable: true }); + try { + await expect(refresh()).resolves.toBe('cookie-token'); + } finally { + Object.defineProperty(navigator, 'locks', { value: undefined, configurable: true }); + } + expect(locks.request.mock.calls[0][0]).toBe('bulwark-token-refresh:0'); + expect(tokenCalls()).toHaveLength(1); + }); + + it('forces a renewal only when the cookie holds the refused token', async () => { + const refresh = await started(); + main?.close(); + main = null; + tokenAnswers.push(new Response(JSON.stringify({ access_token: 'main-token', expires_in: 3600 }), { status: 200 })); + await expect(refresh()).resolves.toBe('cookie-token'); + expect(tokenCalls().map((call) => call.url.includes('force=true'))).toEqual([false, true]); + }); + + it('says so and signs nobody out when the cookie is refused too', async () => { + const refresh = await started(); + main?.close(); + main = null; + tokenAnswers.push(new Response('{}', { status: 401 })); + const ended = vi.fn(); + window.addEventListener(SHARED_SESSION_ENDED_EVENT, ended); + await expect(refresh()).resolves.toBeNull(); + window.removeEventListener(SHARED_SESSION_ENDED_EVENT, ended); + expect(ended).toHaveBeenCalledTimes(1); + expect(useAccountStore.getState().accounts).toHaveLength(1); + expect(useAuthStore.getState().isAuthenticated).toBe(true); + }); +}); + +describe('renewals in a main window', () => { + it('take the same lock, so a main window and a pop-out never race', async () => { + window.history.replaceState(null, '', '/mail'); + const locks = { request: vi.fn((_name: string, _options: object, callback: () => Promise<unknown>) => callback()) }; + Object.defineProperty(navigator, 'locks', { value: locks, configurable: true }); + try { + await useAuthStore.getState().refreshAccessToken({ accountId: 'alice', allowCached: true }); + } finally { + Object.defineProperty(navigator, 'locks', { value: undefined, configurable: true }); + } + expect(locks.request.mock.calls.map((call) => call[0])).toContain('bulwark-token-refresh:0'); + }); +}); diff --git a/stores/account-store.ts b/stores/account-store.ts index e69b1bbba..f0ae567c9 100644 --- a/stores/account-store.ts +++ b/stores/account-store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; +import { createJSONStorage, persist } from 'zustand/middleware'; +import { mainWindowStorage } from '@/lib/popout/window-role'; import { generateAccountId, generateAvatarColor, getMaxAccounts } from '@/lib/account-utils'; import { authHooks } from '@/lib/plugin-hooks'; @@ -250,6 +251,7 @@ export const useAccountStore = create<AccountState>()( }), { name: 'account-registry', + storage: createJSONStorage(mainWindowStorage), partialize: (state) => ({ accounts: state.accounts, activeAccountId: state.activeAccountId, diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 380379382..5c3e6e1c7 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; +import { createJSONStorage, persist } from 'zustand/middleware'; import { JMAPClient, RateLimitError, RequestTimeoutError } from '@/lib/jmap/client'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { useIdentityStore } from './identity-store'; @@ -30,6 +30,9 @@ import { SESSION_TOO_OLD } from '@/lib/session-policy/constants'; import type { Identity } from '@/lib/jmap/types'; import { authHooks } from '@/lib/plugin-hooks'; import { IS_LITE, IS_LITE_STALWART } from '@/lib/lite'; +import { withTokenLock } from '@/lib/auth/token-lock'; +import { requestWindowSession, type SharedAccountSession } from '@/lib/popout/channel'; +import { closePopoutWindow, isPopoutWindow, mainWindowStorage } from '@/lib/popout/window-role'; import { toAsciiEmail } from '@/lib/idn'; import { flushComposers } from '@/lib/compose-flush-registry'; import { sortIdentities } from '@/lib/identity-order'; @@ -119,6 +122,12 @@ interface AuthState { removeAccount: (accountId: string) => void; switchAccount: (accountId: string) => Promise<void>; checkAuth: () => Promise<void>; + /** + * Start-up of a pop-out window, in place of checkAuth: takes the session + * from a main window, or from the remembered cookies when none is open. + * Never evicts an account and starts no token timers. + */ + startSharedSession: (options?: { accountId?: string }) => Promise<'ready' | 'no-session'>; clearError: () => void; syncIdentities: () => void; /** @@ -604,28 +613,32 @@ async function noteSessionAnswer(res: Response, slot: number | undefined): Promi * access token. `remember: false` keeps a rotated refresh token in a cookie * that ends with the browser session. */ -async function fetchSlotAccessToken(slot: number, opts: { force?: boolean; remember?: boolean } = {}): Promise<Response> { - // Renewing would write the tokens of a slot being signed out back. - if (closingSlots.has(slot)) return jsonResponse({ error: 'signed_out' }, 401); - if (!IS_LITE) { - const query = `${opts.force ? '&force=true' : ''}${opts.remember === false ? '&remember=false' : ''}`; - return trackSessionWrite(slot, (signal) => apiFetch(`/api/auth/token?slot=${slot}${query}`, { method: 'PUT', signal })) - .then((res) => noteSessionAnswer(res, slot)); - } - // Like the route's cookie cache: a restore resumes with the token it had, - // since a renewal before the refresh token's `nbf` is refused (#552). - if (!opts.force) { - const cached = readLiteAccessToken(slot); - if (cached) return jsonResponse({ access_token: cached.accessToken, expires_in: cached.expiresIn }); - } - return trackSessionWrite(slot, async () => { - try { - const tokens = await liteRefreshTokens(slot); - return jsonResponse({ access_token: tokens.accessToken, expires_in: tokens.expiresIn }); - } catch (err) { - if (err instanceof LiteLoginError) return jsonResponse({ error: err.code }, liteErrorStatus(err)); - throw err; // network failure - callers treat it as an outage, not a rejection +function fetchSlotAccessToken(slot: number, opts: { force?: boolean; remember?: boolean } = {}): Promise<Response> { + // One at a time across windows: each must send the refresh token the last + // answer wrote (lib/auth/token-lock.ts). + return withTokenLock(slot, async () => { + // Renewing would write the tokens of a slot being signed out back. + if (closingSlots.has(slot)) return jsonResponse({ error: 'signed_out' }, 401); + if (!IS_LITE) { + const query = `${opts.force ? '&force=true' : ''}${opts.remember === false ? '&remember=false' : ''}`; + return trackSessionWrite(slot, (signal) => apiFetch(`/api/auth/token?slot=${slot}${query}`, { method: 'PUT', signal })) + .then((res) => noteSessionAnswer(res, slot)); } + // Like the route's cookie cache: a restore resumes with the token it had, + // since a renewal before the refresh token's `nbf` is refused (#552). + if (!opts.force) { + const cached = readLiteAccessToken(slot); + if (cached) return jsonResponse({ access_token: cached.accessToken, expires_in: cached.expiresIn }); + } + return trackSessionWrite(slot, async () => { + try { + const tokens = await liteRefreshTokens(slot); + return jsonResponse({ access_token: tokens.accessToken, expires_in: tokens.expiresIn }); + } catch (err) { + if (err instanceof LiteLoginError) return jsonResponse({ error: err.code }, liteErrorStatus(err)); + throw err; // network failure - callers treat it as an outage, not a rejection + } + }); }); } @@ -950,6 +963,11 @@ export function redirectToLogin(): void { function navigateToLogin(): void { if (typeof window === 'undefined') return; + // Signing in happens in the main window; a pop-out just goes away. + if (isPopoutWindow()) { + closePopoutWindow(); + return; + } const loginPath = getLocaleLoginPath(); if (window.location.pathname === loginPath) return; @@ -1117,6 +1135,53 @@ function snapshotAndClearForSwitch(activeAccountId: string | null): void { const refreshTimers = new Map<string, ReturnType<typeof setTimeout>>(); const refreshPromises = new Map<string, Promise<string | null>>(); +/** This window is a pop-out on another window's session (startSharedSession). */ +let sharedSessionWindow = false; + +/** Told when a pop-out's session can't be renewed any more and no main window is left to do it. */ +export const SHARED_SESSION_ENDED_EVENT = 'bulwark:shared-session-ended'; + +function bearerToken(header: string): string | null { + return header.startsWith('Bearer ') ? header.slice('Bearer '.length) : null; +} + +/** + * A new token for a pop-out, whose credential the server just refused. It asks + * the main window first: that one owns the renewal, and usually already has + * the next token. Only with no main window left does the pop-out renew, under + * the same lock (fetchSlotAccessToken), and it never signs anyone out. + */ +async function renewSharedToken(accountId: string): Promise<string | null> { + const client = clients.get(accountId); + const account = useAccountStore.getState().getAccountById(accountId); + if (!client || !account) return null; + const rejected = client.getAuthHeader(); + try { + const answer = await requestWindowSession({ accountId, rejectedAuthHeader: rejected }); + const shared = answer?.sessions.find((session) => session.accountId === accountId); + if (shared) return shared.authHeader === rejected ? null : bearerToken(shared.authHeader); + + let res = await fetchSlotAccessToken(account.cookieSlot, { remember: account.rememberMe }); + if (res.ok) { + let { access_token } = await res.json(); + // The cached token is the one the server refused. + if (`Bearer ${access_token}` === rejected) { + res = await fetchSlotAccessToken(account.cookieSlot, { force: true, remember: account.rememberMe }); + if (res.ok) ({ access_token } = await res.json()); + } + if (res.ok) { + await syncStalwartAuthContext(account.serverUrl, account.username, `Bearer ${access_token}`, account.cookieSlot); + return access_token; + } + } + if (res.status === 401 && typeof window !== 'undefined') window.dispatchEvent(new Event(SHARED_SESSION_ENDED_EVENT)); + return null; + } catch (error) { + debug.error('Token renewal in a pop-out failed:', error); + return null; + } +} + // Retry backoff for transiently failed token refreshes (#588). The values // are pseudo-expiries for scheduleRefresh - its "expiry - 60s" math turns // them into delays of 30s, 1m, 2m and 5m (capped). Consecutive failures @@ -2174,6 +2239,13 @@ export const useAuthStore = create<AuthState>()( return refreshPromise; } + if (sharedSessionWindow) { + if (!accountId) return null; + const renewal = renewSharedToken(accountId).finally(() => refreshPromises.delete(accountId)); + refreshPromises.set(accountId, renewal); + return renewal; + } + const account = accountId ? useAccountStore.getState().getAccountById(accountId) : null; const slot = account?.cookieSlot ?? 0; const isActive = !accountId || accountId === get().activeAccountId; @@ -3280,6 +3352,106 @@ export const useAuthStore = create<AuthState>()( } }, + startSharedSession: async (options) => { + sharedSessionWindow = true; + set({ isLoading: true, client: null }); + const accountStore = useAccountStore.getState(); + const answer = await requestWindowSession(); + const sessions = answer?.sessions ?? []; + + // No main window open: what the cookies still hold. A login made + // without "Remember me" has nothing there, and that is no reason to + // evict it: the list is the main window's business. + const restoreFromCookies = async (account: AccountEntry): Promise<JMAPClient | null> => { + if (account.awaitingSignIn) return null; + if (account.authMode === 'oauth') { + const res = await fetchSlotAccessToken(account.cookieSlot, { remember: account.rememberMe }); + if (res.status >= 500) throw new TransientAuthError('Token refresh failed', res.status); + if (!res.ok) return null; + const { access_token } = await res.json(); + await syncStalwartAuthContext(account.serverUrl, account.username, `Bearer ${access_token}`, account.cookieSlot); + return JMAPClient.withBearer(account.serverUrl, access_token, account.username, () => get().refreshAccessToken({ accountId: account.id })); + } + if (!account.rememberMe && !IS_LITE) return null; + const res = await fetchSlotSession(account.cookieSlot); + if (res.status >= 500) throw new TransientAuthError('Session restore failed', res.status); + if (!res.ok) return null; + const session: RememberedSession = await res.json(); + return clientForSession(session); + }; + + const adopt = async (accountId: string, session?: SharedAccountSession): Promise<JMAPClient | null> => { + const account = accountStore.getAccountById(accountId); + if (!account) return null; + try { + if (session?.offlineOnly) throw new TransientAuthError('The main window is offline', 503); + const client = session + ? JMAPClient.withSharedAuth(account.serverUrl, account.username, session.authHeader, session.authMode === 'oauth' + ? () => get().refreshAccessToken({ accountId }) + : undefined) + : await restoreFromCookies(account); + if (!client) return null; + bindClientStatusHandlers(client, set, get, accountId); + await client.connect(); + clients.set(accountId, client); + accountStore.updateAccount(accountId, { isConnected: true, hasError: false }); + return client; + } catch (error) { + debug.error(`A pop-out could not use account ${accountId}:`, error); + if (!isTransientAuthError(error)) return null; + const offlineClient = await startOfflineClient(accountId, account.serverUrl, account.username); + if (offlineClient) clients.set(accountId, offlineClient); + return offlineClient; + } + }; + + const targetId = options?.accountId + ?? answer?.activeAccountId + ?? accountStore.activeAccountId + ?? accountStore.getDefaultAccount()?.id + ?? null; + const account = targetId ? accountStore.getAccountById(targetId) : undefined; + const client = account ? await adopt(account.id, sessions.find((session) => session.accountId === account.id)) : null; + if (!account || !client) { + set({ isLoading: false, isAuthenticated: false, client: null }); + return 'no-session'; + } + + // The window's other logins: a draft may live in one (see + // ComposerDraftData), and the Pro From picker lists them. + const others = sessions.filter((session) => session.accountId !== account.id); + if (others.length > 0) { + void Promise.allSettled(others.map((session) => adopt(session.accountId, session))).then(() => { + set((state) => ({ connectedAccountsRevision: state.connectedAccountsRevision + 1 })); + }); + } + + accountStore.setActiveAccount(account.id); + initializeFeatureStores(client); + let loaded: ReturnType<typeof loadIdentities> = { identities: [], primaryIdentity: null }; + try { + loaded = loadIdentities(await client.getIdentities(), account.username); + } catch (error) { + debug.error('Failed to load identities in a pop-out:', error); + } + set({ + isAuthenticated: true, + isLoading: false, + serverUrl: account.serverUrl, + username: account.username, + client, + ...getClientRateLimitState(client), + identities: loaded.identities, + primaryIdentity: loaded.primaryIdentity, + authMode: account.authMode, + rememberMe: account.rememberMe, + connectionLost: false, + error: null, + activeAccountId: account.id, + }); + return 'ready'; + }, + getClientForAccount: (accountId: string) => { return clients.get(accountId); }, @@ -3301,6 +3473,7 @@ export const useAuthStore = create<AuthState>()( }), { name: 'auth-storage', + storage: createJSONStorage(mainWindowStorage), partialize: (state) => { // Don't persist unauthenticated state - prevents resurrecting stale sessions if (!state.isAuthenticated) return {};