From b2931ced3ed784204d9d6d15196111a5eea23e7c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:44:02 +0100 Subject: [PATCH] feat: bring desktop windows back at the next start, open pop-outs from a warm spare and ask natively before discarding a draft --- app/(main)/[locale]/popout/spare/page.tsx | 7 ++ .../__tests__/composer-close-guard.test.tsx | 35 +++++- components/email/email-composer.tsx | 41 ++++++- .../__tests__/popout-compose-app.test.tsx | 45 ++++++++ components/popout/popout-compose-app.tsx | 64 ++++++++++- components/popout/popout-spare-app.tsx | 45 ++++++++ .../settings/desktop-window-settings.tsx | 26 +++++ components/settings/general-settings.tsx | 2 + lib/desktop/preferences.ts | 71 +++++++++++++ lib/desktop/types.ts | 100 +++++++++++++++++- lib/desktop/window-restore.ts | 40 +++++++ lib/draft-composer-data.ts | 60 +++++++++++ lib/popout/open.ts | 6 ++ lib/popout/window-role.ts | 10 +- locales/ar/common.json | 15 +++ locales/ca/common.json | 15 +++ locales/cs/common.json | 15 +++ locales/da/common.json | 15 +++ locales/de/common.json | 15 +++ locales/en/common.json | 15 +++ locales/es/common.json | 15 +++ locales/et/common.json | 15 +++ locales/fa/common.json | 15 +++ locales/fr/common.json | 15 +++ locales/he/common.json | 15 +++ locales/hu/common.json | 15 +++ locales/it/common.json | 15 +++ locales/ja/common.json | 15 +++ locales/ko/common.json | 15 +++ locales/lv/common.json | 15 +++ locales/mn/common.json | 15 +++ locales/nb/common.json | 15 +++ locales/nl/common.json | 15 +++ locales/pl/common.json | 15 +++ locales/pt/common.json | 15 +++ locales/ro/common.json | 15 +++ locales/ru/common.json | 15 +++ locales/sk/common.json | 15 +++ locales/th/common.json | 15 +++ locales/tr/common.json | 15 +++ locales/uk/common.json | 15 +++ locales/vi/common.json | 15 +++ locales/zh-TW/common.json | 15 +++ locales/zh/common.json | 15 +++ 44 files changed, 987 insertions(+), 15 deletions(-) create mode 100644 app/(main)/[locale]/popout/spare/page.tsx create mode 100644 components/popout/popout-spare-app.tsx create mode 100644 components/settings/desktop-window-settings.tsx create mode 100644 lib/desktop/preferences.ts create mode 100644 lib/desktop/window-restore.ts create mode 100644 lib/draft-composer-data.ts diff --git a/app/(main)/[locale]/popout/spare/page.tsx b/app/(main)/[locale]/popout/spare/page.tsx new file mode 100644 index 000000000..1eafe6639 --- /dev/null +++ b/app/(main)/[locale]/popout/spare/page.tsx @@ -0,0 +1,7 @@ +import { PopoutSpareApp } from "@/components/popout/popout-spare-app"; + +// The desktop app's warm spare (components/popout/popout-spare-app.tsx). Not in +// Lite: scripts/lite removes this tree. +export default function PopoutSparePage() { + return ; +} diff --git a/components/email/__tests__/composer-close-guard.test.tsx b/components/email/__tests__/composer-close-guard.test.tsx index 8ac5e5b82..9ef9370a7 100644 --- a/components/email/__tests__/composer-close-guard.test.tsx +++ b/components/email/__tests__/composer-close-guard.test.tsx @@ -1,5 +1,5 @@ import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react'; -import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; import React from 'react'; import { EmailComposer } from '../email-composer'; import { useAuthStore } from '@/stores/auth-store'; @@ -235,6 +235,39 @@ describe('composer close guard with a follow-up', () => { expect(afterClose).not.toHaveBeenCalled(); }); + describe('in the desktop app', () => { + afterEach(() => { + window.bulwarkDesktop = undefined; + }); + + const nativeBridge = (answer: number) => { + const confirm = vi.fn(async () => answer); + window.bulwarkDesktop = { version: 1, platform: 'win32', dialogs: { confirm } } as never; + return confirm; + }; + + it('asks through the OS, and shows no dialog of its own', async () => { + const confirm = nativeBridge(2); + const { requestCloseRef, afterClose, onClose } = renderWithCloseRef(); + act(() => requestCloseRef.current!(afterClose)); + await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1)); + expect(confirm.mock.calls[0]).toMatchObject([{ message: 'close_draft_title', buttons: ['save', 'discard', 'cancel'], defaultId: 0 }]); + expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument(); + // The last button is Cancel: nothing happens. + await act(async () => {}); + expect(onClose).not.toHaveBeenCalled(); + expect(afterClose).not.toHaveBeenCalled(); + }); + + it('Discard discards, and the follow-up runs', async () => { + nativeBridge(1); + const { requestCloseRef, afterClose, onClose } = renderWithCloseRef(); + act(() => requestCloseRef.current!(afterClose)); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1)); + }); + }); + it('runs the follow-up after discarding', async () => { const { requestCloseRef, afterClose, onClose } = renderWithCloseRef(); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index d4ff3a9a8..2e25fdb4d 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -166,6 +166,8 @@ 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 { getDesktopBridge } from "@/lib/desktop/bridge"; +import { noteOpenDraft } from "@/lib/desktop/window-restore"; import type { RescuedDraft } from "@/lib/popout/channel"; import { toUnicodeEmail } from "@/lib/idn"; @@ -1936,6 +1938,14 @@ export function EmailComposer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // A composer that comes back with the app: a draft it opened on counts from the start. + // Gone with the composer: sent, discarded or closed. + useEffect(() => { + if (draftIdRef.current) noteOpenDraft(effectiveSessionKey, { draftId: draftIdRef.current, localAccountId: draftLocalAccountIdRef.current, ownerAccountId: draftAccountIdRef.current }); + return () => noteOpenDraft(effectiveSessionKey, null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [effectiveSessionKey]); + // Auto-save draft to server on page close (best-effort) const ownWindowRef = useRef(ownWindow); ownWindowRef.current = ownWindow; @@ -3005,6 +3015,8 @@ export function EmailComposer({ draftIdRef.current = savedDraftId; draftAccountIdRef.current = draftAccountId; draftLocalAccountIdRef.current = savingLogin; + // Before a save's promise settles: quitting waits for it, then keeps this list. + noteOpenDraft(effectiveSessionKey, { draftId: savedDraftId, localAccountId: savingLogin, ownerAccountId: draftAccountId }); setDraftId(savedDraftId); setDraftAccountId(draftAccountId); setDraftLocalAccountId(savingLogin); @@ -4418,6 +4430,29 @@ export function EmailComposer({ emitClose(); }; + /** The question before closing or discarding: the OS's own in the desktop app. */ + const openCloseDialog = (kind: 'discard' | 'close') => { + const dialogs = getDesktopBridge()?.dialogs; + if (!dialogs) { + setCloseDialogKind(kind); + setShowCloseDialog(true); + return; + } + const discarding = kind === 'discard'; + const buttons = discarding ? [t('discard'), t('cancel')] : [tCommon('save'), t('discard'), t('cancel')]; + void dialogs.confirm({ + type: 'warning', + message: discarding ? t('discard_draft_title') : t('close_draft_title'), + detail: discarding ? t('discard_draft_message') : t('close_draft_message'), + buttons, + defaultId: discarding ? 1 : 0, + }).then((choice) => { + if (buttons[choice] === t('discard')) handleDiscardAndClose(); + else if (!discarding && choice === 0) void handleSaveDraftAndClose(); + else afterCloseRef.current = null; + }); + }; + // Discard deletes the draft, also one that has no unsaved changes, such as // a scheduled message reopened with "Cancel and edit". It used to share the // X button's path, which closed such a composer and kept the draft. @@ -4429,8 +4464,7 @@ export function EmailComposer({ return; } afterCloseRef.current = null; - setCloseDialogKind('discard'); - setShowCloseDialog(true); + openCloseDialog('discard'); }; const handleClose = (afterClose?: () => void) => { @@ -4442,8 +4476,7 @@ export function EmailComposer({ && !saveFailedRef.current && !attachmentsRef.current.some(att => att.uploading || att.error); if (isDirtyRef.current && !savedAlready) { - setCloseDialogKind('close'); - setShowCloseDialog(true); + openCloseDialog('close'); } else { cleanClose(); } diff --git a/components/popout/__tests__/popout-compose-app.test.tsx b/components/popout/__tests__/popout-compose-app.test.tsx index 3ff5cf883..2a02faf0f 100644 --- a/components/popout/__tests__/popout-compose-app.test.tsx +++ b/components/popout/__tests__/popout-compose-app.test.tsx @@ -67,6 +67,51 @@ function openAt(handoffId: string) { render(); } +describe('a composer coming back on its draft', () => { + const closeSelf = vi.fn(); + + beforeEach(() => { + closeSelf.mockClear(); + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { closeSelf } } as never; + }); + + afterEach(() => { + window.bulwarkDesktop = undefined; + }); + + const draft = { + id: 'd1', subject: 'Half-written', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ email: 'me@example.org' }], + bodyValues: { t: { value: 'Text' } }, textBody: [{ partId: 't', type: 'text/plain' }], htmlBody: [], attachments: [{ blobId: 'b1', name: 'a.pdf', type: 'application/pdf', size: 3 }], + }; + + const openDraft = (getEmail: ReturnType, search = 'draft=d1&account=alice&owner=group') => { + useAuthStore.setState({ client: { getEmail } as never, getClientForAccount: () => undefined } as never); + window.history.replaceState(null, '', `/en/popout/compose?${search}`); + render(); + }; + + it('reads the draft from where it lives, and opens the composer on it, id and all', async () => { + const getEmail = vi.fn(async () => draft); + openDraft(getEmail); + await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Half-written')); + expect(getEmail).toHaveBeenCalledWith('d1', 'group'); + // Saving goes over this draft, not next to it, and its files stay. + expect(composer?.initialData).toMatchObject({ draftId: 'd1', draftAccountId: 'group', draftLocalAccountId: 'alice', to: 'a@example.org' }); + expect((composer?.initialData as unknown as { attachments: unknown[] }).attachments).toHaveLength(1); + }); + + it('a draft that has gone since (sent elsewhere) closes the window', async () => { + openDraft(vi.fn(async () => null)); + await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1)); + expect(screen.queryByTestId('composer')).toBeNull(); + }); + + it('a server that doesn\'t answer closes it too: the draft is safe on the server', async () => { + openDraft(vi.fn(async () => { throw new Error('offline'); })); + await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1)); + }); +}); + describe('PopoutComposeApp', () => { it('tells the opening window to let go only with the composer on screen', async () => { const handoff = createComposeHandoff()!; diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx index 6f0bebeff..7aaa4f709 100644 --- a/components/popout/popout-compose-app.tsx +++ b/components/popout/popout-compose-app.tsx @@ -18,6 +18,8 @@ import { announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watch import { acknowledgeComposeHandoff, receiveComposeHandoff, rememberComposePayload, rememberedComposePayload, watchHandoffCancel } from "@/lib/popout/compose-handoff"; import type { ComposePopoutPayload } from "@/lib/popout/compose-payload"; import { closePopoutWindow } from "@/lib/popout/window-role"; +import { composerDataFromDraft } from "@/lib/draft-composer-data"; +import { useIdentityStore } from "@/stores/identity-store"; import { PopoutShell, PopoutSkeleton } from "./popout-shell"; /** A warning with no main window left to show it stays this long before the window goes. */ @@ -31,10 +33,60 @@ const EMPTY_PAYLOAD: ComposePopoutPayload = { mode: "compose" }; /** How long a reloaded window waits to hear what became of the text it handed over on the way out. */ const RESCUE_ANSWER_MS = 2000; -function readLaunch(): { handoffId: string | null; accountId?: string } { +function readLaunch(): { handoffId: string | null; accountId?: string; draftId?: string; ownerAccountId?: 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 }; + return { + handoffId: search.get("handoff"), + accountId: search.get("account") ?? undefined, + draftId: search.get("draft") ?? undefined, + ownerAccountId: search.get("owner") ?? undefined, + }; +} + +/** + * The composer that was open when the desktop app quit, back on its draft. A draft + * that has gone since (sent or deleted elsewhere) means there's nothing to show. + */ +function RestoredDraft({ draftId, accountId, ownerAccountId, onReady }: { + draftId: string; + accountId?: string; + ownerAccountId?: string; + onReady: (payload: ComposePopoutPayload) => void; +}) { + const t = useTranslations(); + const tWindows = useTranslations("pwaWindows"); + const client = useAuthStore((s) => (accountId ? s.getClientForAccount(accountId) : undefined) ?? s.client); + const identities = useIdentityStore((s) => s.identities); + useEffect(() => { + if (!client) return; + let cancelled = false; + void client.getEmail(draftId, ownerAccountId).then((draft) => { + if (cancelled) return; + if (!draft) { + closePopoutWindow(); + return; + } + onReady({ + mode: "compose", + initialData: composerDataFromDraft(draft, { + accountId: ownerAccountId, + localAccountId: accountId, + identities, + noSubject: t("email_composer.no_subject"), + }), + }); + }).catch((error) => { + debug.error("Failed to reopen a draft:", error); + if (!cancelled) closePopoutWindow(); + }); + return () => { + cancelled = true; + }; + // Once per client: the draft doesn't change under a window still opening. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [client, draftId, ownerAccountId]); + return ; } /** The composer alone, in its own window: sending, autosave and discard go through the shared stores, as in a Pro tab. */ @@ -181,8 +233,8 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; /** `/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(handoffId ? null : EMPTY_PAYLOAD); + const [{ handoffId, accountId, draftId, ownerAccountId }] = useState(readLaunch); + const [payload, setPayload] = useState(handoffId || draftId ? null : EMPTY_PAYLOAD); useEffect(() => { if (!handoffId) return; @@ -216,7 +268,9 @@ export function PopoutComposeApp() { return ( - {payload ? : } + {payload ? + : draftId ? + : } ); } diff --git a/components/popout/popout-spare-app.tsx b/components/popout/popout-spare-app.tsx new file mode 100644 index 000000000..26f67104e --- /dev/null +++ b/components/popout/popout-spare-app.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import { withBasePath } from "@/lib/browser-navigation"; +import { PopoutComposeApp } from "./popout-compose-app"; +import { PopoutMessageApp } from "./popout-message-app"; + +/** The message id a pop-out address names. */ +function messageIdOf(pathname: string): string { + const segments = pathname.split("/").filter(Boolean); + const at = segments.lastIndexOf("message"); + return at >= 0 && segments[at + 1] ? decodeURIComponent(segments[at + 1]) : ""; +} + +/** + * The desktop app's warm spare: a hidden window with the pop-out code loaded and + * nothing signed in. When a pop-out is wanted the shell gives it an address. The + * address bar changes without a load, which is where the pop-outs read their + * arguments from, and the right one renders in a window that's already up. + */ +export function PopoutSpareApp() { + const [target, setTarget] = useState(null); + + useEffect(() => { + const spare = getDesktopBridge()?.windows?.spare; + if (!spare) { + // Someone typed the address, or a browser followed a link. + window.location.replace(withBasePath("/")); + return; + } + const stop = spare.onActivate((url) => { + const next = new URL(url, window.location.href); + window.history.replaceState(null, "", `${next.pathname}${next.search}`); + setTarget(next); + spare.taken(); + }); + spare.ready(); + return stop; + }, []); + + if (!target) return
; + if (/\/popout\/compose\/?$/.test(target.pathname)) return ; + return ; +} diff --git a/components/settings/desktop-window-settings.tsx b/components/settings/desktop-window-settings.tsx new file mode 100644 index 000000000..3d604728c --- /dev/null +++ b/components/settings/desktop-window-settings.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { hasDesktopWindows } from "@/lib/popout/support"; +import { setDesktopPref, useDesktopPrefs } from "@/lib/desktop/preferences"; +import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section"; + +/** How the desktop app's windows behave. Only there: a browser has nothing to set. */ +export function DesktopWindowSettings() { + const t = useTranslations("desktopUi.settings"); + const prefs = useDesktopPrefs(); + const [shown, setShown] = useState(false); + useEffect(() => setShown(hasDesktopWindows()), []); + if (!shown) return null; + return ( + + + setDesktopPref("openOnDoubleClick", checked)} /> + + + setDesktopPref("restoreWindows", checked)} /> + + + ); +} diff --git a/components/settings/general-settings.tsx b/components/settings/general-settings.tsx index 41d2ba072..7dc969209 100644 --- a/components/settings/general-settings.tsx +++ b/components/settings/general-settings.tsx @@ -9,6 +9,7 @@ import { useTour } from '@/components/tour/tour-provider'; import { Button } from '@/components/ui/button'; import { PlayCircle } from '@/components/icons'; import { IS_LITE } from '@/lib/lite'; +import { DesktopWindowSettings } from './desktop-window-settings'; /** * The General page: language and time, which links the browser hands to @@ -26,6 +27,7 @@ export function GeneralSettings({ supportsCalendar }: { supportsCalendar: boolea {!IS_LITE && } +