0 && !(mvShell && !cursorOnly && selectedEmail);
const singleKeyShortcuts = useSettingsStore((s) => s.singleKeyShortcuts);
useKeyboardShortcuts({
@@ -1259,6 +1264,59 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
},
}, isAuthenticated && !!client);
+ // The desktop app's menu bar: the same handlers as the keys, and what's enabled
+ // from what's on screen (lib/desktop/commands.ts).
+ const desktopTarget = !showComposer && (!!selectedEmail || selectedEmailKeys.size > 0);
+ const desktopSingle = !showComposer && !!selectedEmail;
+ const mailLayoutSetting = useSettingsStore((s) => s.mailLayout);
+ const layoutFixed = interfaceLayout === 'mountain-view';
+ useDesktopCommands({
+ 'file.newMessage': keyboardHandlers.onCompose,
+ 'edit.search': keyboardHandlers.onFocusSearch,
+ 'view.refresh': () => void handleManualRefresh(),
+ 'view.layout.split': () => useSettingsStore.getState().updateSetting('mailLayout', 'split'),
+ 'view.layout.horizontal': () => useSettingsStore.getState().updateSetting('mailLayout', 'horizontal'),
+ 'view.layout.focus': () => useSettingsStore.getState().updateSetting('mailLayout', 'focus'),
+ 'message.reply': keyboardHandlers.onReply,
+ 'message.replyAll': keyboardHandlers.onReplyAll,
+ 'message.forward': keyboardHandlers.onForward,
+ 'message.markRead': () => void keyboardHandlers.onMarkAsRead?.(),
+ 'message.markUnread': () => void keyboardHandlers.onMarkAsUnread?.(),
+ 'message.star': () => void keyboardHandlers.onToggleStar?.(),
+ 'message.move': () => { if (selectedEmail) setMoveDialogEmail(selectedEmail); },
+ 'message.archive': keyboardHandlers.onArchive ? () => void keyboardHandlers.onArchive?.() : undefined,
+ 'message.spam': () => void keyboardHandlers.onToggleSpam?.(),
+ 'message.delete': () => void keyboardHandlers.onDelete?.(),
+ 'message.openWindow': () => { if (selectedEmail) openEmailInWindow?.(selectedEmail); },
+ 'go.inbox': () => keyboardHandlers.onGoToMailbox('inbox'),
+ 'go.sent': () => keyboardHandlers.onGoToMailbox('sent'),
+ 'go.drafts': () => keyboardHandlers.onGoToMailbox('drafts'),
+ 'go.nextMessage': keyboardHandlers.onNextEmail,
+ 'go.previousMessage': keyboardHandlers.onPreviousEmail,
+ 'go.nextUnread': keyboardHandlers.onNextUnread,
+ }, {
+ 'view.refresh': true,
+ 'view.layout.split': { enabled: !layoutFixed, checked: mailLayoutSetting === 'split' },
+ 'view.layout.horizontal': { enabled: !layoutFixed, checked: mailLayoutSetting === 'horizontal' },
+ 'view.layout.focus': { enabled: !layoutFixed, checked: mailLayoutSetting === 'focus' },
+ 'message.reply': desktopSingle,
+ 'message.replyAll': desktopSingle,
+ 'message.forward': desktopSingle,
+ 'message.markRead': desktopTarget && !(selectedEmail && selectedEmailKeys.size === 0 && selectedEmail.keywords?.$seen),
+ 'message.markUnread': desktopTarget && !(selectedEmail && selectedEmailKeys.size === 0 && !selectedEmail.keywords?.$seen),
+ 'message.star': desktopSingle,
+ 'message.move': desktopSingle,
+ 'message.archive': desktopTarget && archiveActionEnabled,
+ 'message.spam': desktopTarget,
+ 'message.delete': desktopTarget,
+ 'message.openWindow': desktopSingle && !!openEmailInWindow,
+ 'go.sent': true,
+ 'go.drafts': true,
+ 'go.nextMessage': !showComposer,
+ 'go.previousMessage': !showComposer,
+ 'go.nextUnread': !showComposer,
+ }, isAuthenticated && !isEmbedded);
+
// Shared mail refresh: re-fetch mailboxes (unread counts) + the active list
// via JMAP. Used by both the browser refresh gestures and the toolbar button.
const [isManualRefreshing, setIsManualRefreshing] = useState(false);
@@ -2266,7 +2324,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const composerSource = usePinnedPerSession(composerSessionId, selectedEmail ? emailWriteSource(selectedEmail, client) : undefined);
const popoutsOn = usePopoutsAvailable();
- const openEmailInWindow = useOpenEmailInWindow();
+ const desktopDoubleClick = useDesktopPrefs().openOnDoubleClick && hasDesktopWindows() && !isMobile && !!openEmailInWindow;
// Text from a closing pop-out that couldn't be saved: the "Continue draft"
// slot, once it is free.
useEffect(() => {
@@ -2604,17 +2662,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
draft = await emailHooks.onBeforeEditDraft.transform(draft);
- const { body: draftBody, bodyText, plainTextMode: draftPlainTextMode } = getDraftComposerBody(draft);
+ const { bodyText } = getDraftComposerBody(draft);
- // Restore the identity the draft was composed with. Match the saved From
- // (name + address) against the same list the composer renders — the flat
- // cross-account list when multi-account is on — so a draft from a non-active
- // account, or one of two identities sharing an address, is restored rather
- // than reset to the default.
+ // The list the composer renders: the flat cross-account one when multi-account is on.
const composerIdentities = multiAccountIdentities.enabled
? multiAccountIdentities.allIdentities
: identities;
- const matchedIdentityId = findDraftIdentityIdForLogin(composerIdentities, draft.from?.[0], localAccountId);
const draftRecipients = (list: typeof draft.to) => (list ?? []).map(a => a.email).filter(Boolean);
if (!(await withComposeGate('compose', {
@@ -2627,46 +2680,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Increment session ID to force the composer to remount with fresh state,
// even if it was already open (e.g. right-clicking a draft while composing).
setComposerSessionId(id => id + 1);
- setPendingDraft({
- to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '',
- cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '',
- bcc: draft.bcc?.map(a => a.email).filter(Boolean).join(', ') || '',
- // Drafts saved before #1189 stored an empty subject as the composer's
- // "(No Subject)" placeholder; reopen those with an empty field.
- subject: draft.subject && draft.subject !== t('email_composer.no_subject') ? draft.subject : '',
- body: draftBody,
- // Re-open in the format the draft was written in (#1022).
- plainTextMode: draftPlainTextMode,
- showCc: (draft.cc?.length || 0) > 0,
- showBcc: (draft.bcc?.length || 0) > 0,
- selectedIdentityId: matchedIdentityId,
- subAddressTag: '',
- mode: 'compose',
- draftId: draft.id,
- draftAccountId: accountId,
- draftLocalAccountId: localAccountId,
- // A reply draft re-opens in compose mode; its threading headers must
- // come along or the reply leaves its thread.
- inReplyTo: draft.inReplyTo ?? undefined,
- references: draft.references ?? undefined,
- priority: messagePriorityFromHeaders(draft.headers),
- // A sent message brought back (Undo, Cancel and edit) has no draft
- // marker: its SIO-Label says which label it went out with.
- classification: reopenedLabelChoice(draft.headers, localAccountId),
- // Existing server-side attachments must ride along, or the composer
- // starts empty and the next save/send silently rebuilds the draft
- // without them (#849).
- attachments: (draft.attachments ?? [])
- .filter(a => !!a.blobId)
- .map(a => ({
- blobId: a.blobId,
- name: a.name,
- type: a.type,
- size: a.size,
- cid: a.cid,
- disposition: a.disposition,
- })),
- });
+ setPendingDraft(composerDataFromDraft(draft, {
+ accountId,
+ localAccountId,
+ identities: composerIdentities,
+ noSubject: t('email_composer.no_subject'),
+ }));
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -5539,7 +5558,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onEmailSelect={handleEmailSelect}
onOpenSearchResult={handleEmailSelect}
onEmailPrefetch={handleEmailPrefetch}
- onEmailDoubleClick={isEmbedded ? ((email) => {
+ onEmailDoubleClick={!isEmbedded ? (desktopDoubleClick ? (email) => {
+ // A draft goes back in the composer, as it does from a single click.
+ if (email.keywords?.$draft) void handleEditDraft(email);
+ else openEmailInWindow?.(email);
+ } : undefined) : ((email) => {
const store = useProTabStore.getState();
const hasSplit = store.tabs.some((tab) => tab.paneId === 'split');
// In a split, open on the *other* side so this list stays
@@ -5570,7 +5593,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
mailboxId: selectedMailbox,
title: email.subject?.trim() || t('email_composer.new_message'),
}, { pane: targetPane, reuseReader: hasSplit });
- }) : undefined}
+ })}
onOpenConversation={handleOpenConversation}
// Context menu handlers
onReply={(email) => {
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/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx
index 97a8c6b67..aeaf0f204 100644
--- a/components/pro/pro-email-tab-body.tsx
+++ b/components/pro/pro-email-tab-body.tsx
@@ -34,6 +34,8 @@ 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 { useDesktopCommands } from '@/lib/desktop/commands';
+import { isPopoutWindow } from '@/lib/popout/window-role';
import dynamic from 'next/dynamic';
// Only needed when the device policy turns downloads off.
@@ -450,6 +452,29 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
setEmail({ ...email, keywords });
}, [email, setEmailKeywordsLocal]);
+ // In a pop-out this message is all there is, so the menu bar's Message menu means it.
+ const inOwnWindow = typeof window !== 'undefined' && isPopoutWindow();
+ const seen = !!email?.keywords?.$seen;
+ useDesktopCommands({
+ 'message.reply': () => handleReply(),
+ 'message.replyAll': handleReplyAll,
+ 'message.forward': handleForward,
+ 'message.markRead': () => { if (email) void handleMarkAsRead(email.id, true); },
+ 'message.markUnread': () => { if (email) void handleMarkAsRead(email.id, false); },
+ 'message.star': () => void handleToggleStar(),
+ 'message.archive': archiveActionEnabled ? () => void handleArchive() : undefined,
+ 'message.delete': () => void handleDelete(),
+ }, {
+ 'message.reply': !!email,
+ 'message.replyAll': !!email,
+ 'message.forward': !!email,
+ 'message.markRead': !!email && !seen,
+ 'message.markUnread': !!email && seen,
+ 'message.star': !!email,
+ 'message.archive': !!email && archiveActionEnabled,
+ 'message.delete': !!email,
+ }, inOwnWindow);
+
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
if (!client || !email) return;
try {
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 0a03272bf..370c76b2e 100644
--- a/components/settings/general-settings.tsx
+++ b/components/settings/general-settings.tsx
@@ -10,6 +10,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
@@ -28,6 +29,7 @@ export function GeneralSettings({ supportsCalendar }: { supportsCalendar: boolea
{!IS_LITE && }
+
- ,
+
+ ,
document.body
);
}
@@ -578,11 +618,11 @@ export function ContextMenuItem({
{Icon &&