feat: bring desktop windows back at the next start, open pop-outs from a warm spare and ask natively before discarding a draft

This commit is contained in:
Linus Rath
2026-11-03 10:44:02 +01:00
parent 3b1fa089aa
commit b2931ced3e
44 changed files with 987 additions and 15 deletions
@@ -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 <PopoutSpareApp />;
}
@@ -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();
+37 -4
View File
@@ -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();
}
@@ -67,6 +67,51 @@ function openAt(handoffId: string) {
render(<PopoutComposeApp />);
}
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<typeof vi.fn>, 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(<PopoutComposeApp />);
};
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()!;
+59 -5
View File
@@ -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 <PopoutSkeleton label={tWindows("opening")} />;
}
/** 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<ComposePopoutPayload | null>(handoffId ? null : EMPTY_PAYLOAD);
const [{ handoffId, accountId, draftId, ownerAccountId }] = useState(readLaunch);
const [payload, setPayload] = useState<ComposePopoutPayload | null>(handoffId || draftId ? null : EMPTY_PAYLOAD);
useEffect(() => {
if (!handoffId) return;
@@ -216,7 +268,9 @@ export function PopoutComposeApp() {
return (
<PopoutShell kind="compose" accountId={accountId}>
{payload ? <PopoutComposer payload={payload} handoffId={handoffId} /> : <PopoutSkeleton label={t("opening")} />}
{payload ? <PopoutComposer payload={payload} handoffId={handoffId} />
: draftId ? <RestoredDraft draftId={draftId} accountId={accountId} ownerAccountId={ownerAccountId} onReady={setPayload} />
: <PopoutSkeleton label={t("opening")} />}
</PopoutShell>
);
}
+45
View File
@@ -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<URL | null>(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 <div data-popout-spare="" className="h-dvh bg-background" />;
if (/\/popout\/compose\/?$/.test(target.pathname)) return <PopoutComposeApp />;
return <PopoutMessageApp id={messageIdOf(target.pathname)} />;
}
@@ -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 (
<SettingsSection id="desktop-windows" title={t("title")}>
<SettingItem label={t("double_click_label")} description={t("double_click_description")}>
<ToggleSwitch checked={prefs.openOnDoubleClick} onChange={(checked) => setDesktopPref("openOnDoubleClick", checked)} />
</SettingItem>
<SettingItem label={t("restore_label")} description={t("restore_description")}>
<ToggleSwitch checked={prefs.restoreWindows} onChange={(checked) => setDesktopPref("restoreWindows", checked)} />
</SettingItem>
</SettingsSection>
);
}
+2
View File
@@ -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 && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
<DesktopWindowSettings />
<SettingsSection id="tour" title={tTour('settings_section')}>
<SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}>
<Button
+71
View File
@@ -0,0 +1,71 @@
/**
* What the desktop app's windows do. These belong to this computer, not the account
* (one user can have a laptop and a desktop that differ), so they stay out of the
* synced settings. The shell gets its copy of "restore" from the host.
*/
import { useSyncExternalStore } from 'react';
const STORAGE_KEY = 'bulwark-desktop-window-prefs';
export interface DesktopWindowPrefs {
/** Double-clicking a message opens it in its own window. */
openOnDoubleClick: boolean;
/** The windows open at quit come back at the next start. */
restoreWindows: boolean;
}
const DEFAULTS: DesktopWindowPrefs = { openOnDoubleClick: true, restoreWindows: true };
let cached: DesktopWindowPrefs | null = null;
const listeners = new Set<() => void>();
export function readDesktopPrefs(): DesktopWindowPrefs {
if (cached) return cached;
let stored: Partial<DesktopWindowPrefs> = {};
try {
const raw = typeof localStorage === 'undefined' ? null : localStorage.getItem(STORAGE_KEY);
if (raw) stored = JSON.parse(raw) as Partial<DesktopWindowPrefs>;
} catch {
// Damaged: the defaults.
}
cached = {
openOnDoubleClick: typeof stored.openOnDoubleClick === 'boolean' ? stored.openOnDoubleClick : DEFAULTS.openOnDoubleClick,
restoreWindows: typeof stored.restoreWindows === 'boolean' ? stored.restoreWindows : DEFAULTS.restoreWindows,
};
return cached;
}
export function setDesktopPref<K extends keyof DesktopWindowPrefs>(key: K, value: DesktopWindowPrefs[K]): void {
cached = { ...readDesktopPrefs(), [key]: value };
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(cached));
} catch {
// Full or unavailable: it holds until the window closes.
}
for (const listener of [...listeners]) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
// Another window changed it.
const onStorage = (event: StorageEvent) => {
if (event.key !== STORAGE_KEY) return;
cached = null;
listener();
};
window.addEventListener('storage', onStorage);
return () => {
listeners.delete(listener);
window.removeEventListener('storage', onStorage);
};
}
export function useDesktopPrefs(): DesktopWindowPrefs {
return useSyncExternalStore(subscribe, readDesktopPrefs, () => DEFAULTS);
}
/** Test helper. */
export function resetDesktopPrefsCache(): void {
cached = null;
}
+96 -4
View File
@@ -16,6 +16,8 @@ export interface DesktopWindowInfo {
type: DesktopWindowType;
title: string;
focused: boolean;
/** False for a main window hidden while pop-outs are open. */
visible?: boolean;
}
export interface DesktopWindowsBridge {
@@ -34,13 +36,72 @@ export interface DesktopWindowsBridge {
list?: () => Promise<DesktopWindowInfo[]>;
/** Fires when a window opens, closes, retitles or takes the focus. Returns the unsubscribe. */
onChanged?: (listener: (windows: DesktopWindowInfo[]) => void) => () => void;
/**
* This page has saved or discarded and closes its window. Goes through the shell
* so bounds get stored and no second save runs. Works however the window opened.
*/
closeSelf?: () => void;
/** What this window's open drafts come back as at the next start: pop-out composer addresses. */
setRestore?: (urls: string[]) => void;
setRestoreEnabled?: (enabled: boolean) => void;
/** Signed in, with a session to lend: windows from last time can open. */
sessionReady?: () => void;
/** The warm spare's page, see components/popout/popout-spare-app.tsx. */
spare?: {
ready: () => void;
taken: () => void;
onActivate: (listener: (url: string) => void) => () => void;
};
}
export interface DesktopThemeBridge {
/** The page's colours, for title bar buttons and the first frame of new windows. */
set: (theme: { dark: boolean; background: string; foreground?: string }) => void;
/** Fires when the OS switches between light and dark. Returns the unsubscribe. */
onSystemChange?: (listener: (system: { dark: boolean; highContrast: boolean }) => void) => () => void;
/** Fires when the OS switches between light and dark, contrast or accent. Returns the unsubscribe. */
onSystemChange?: (listener: (system: DesktopSystemTheme) => void) => () => void;
/** How the OS looks now. */
system?: () => Promise<DesktopSystemTheme>;
}
export interface DesktopSystemTheme {
dark: boolean;
highContrast: boolean;
/** The OS accent colour as #rrggbb, where the OS has one and tells. */
accent?: string;
}
export type DesktopMenuState = Record<string, boolean | { enabled?: boolean; checked?: boolean }>;
/** A native menu built from an HTML one (lib/desktop/native-menu.ts). */
export interface DesktopNativeMenuItem {
/** Handed back when this item is chosen. Left out of separators and submenus. */
id?: string;
label?: string;
type?: 'normal' | 'separator' | 'checkbox' | 'radio';
enabled?: boolean;
checked?: boolean;
submenu?: DesktopNativeMenuItem[];
}
export interface DesktopMenuBarEntry {
id: string;
label: string;
/** The letter that opens it with Alt, lower case. */
mnemonic?: string;
}
export interface DesktopMenuBarEvent {
visible: boolean;
/** Open this menu right away: Alt plus its letter. */
open?: string;
}
/** What a page answers when asked to save before a window goes. */
export interface DesktopFlushAnswer {
failed?: number;
timedOut?: boolean;
/** Mail waiting to be sent. */
unsent?: number;
}
export interface DesktopMenuBridge {
@@ -48,12 +109,41 @@ export interface DesktopMenuBridge {
* What the app menu shows as enabled or checked, by command id. The shell
* keeps the last state per window and applies it when that window has focus.
*/
setState: (state: Record<string, boolean | { enabled?: boolean; checked?: boolean }>) => void;
setState: (state: DesktopMenuState) => void;
/** A menu command to run. Returns the unsubscribe. */
onCommand?: (listener: (id: string) => void) => () => void;
/** Shows a native menu. At the pointer when `at` is left out. Answers with the id picked, or null. */
popup?: (items: DesktopNativeMenuItem[], at?: { x: number; y: number }) => Promise<string | null>;
/** The strip that stands in for a menu bar in a window with no native bar. */
bar?: {
entries: () => Promise<DesktopMenuBarEntry[]>;
/** Opens that menu natively, at page coordinates. */
open: (id: string, at: { x: number; y: number }) => void;
onEvent: (listener: (event: DesktopMenuBarEvent) => void) => () => void;
};
}
export interface DesktopShellBridge {
/** http, https and mailto only. */
openExternal: (url: string) => void;
/** The shell's menus follow `ui`. Spellcheck follows both. */
setLanguages?: (languages: { ui: string; composer?: string[] }) => void;
}
export interface DesktopDialogsBridge {
/** A native question. Answers with the index of the button chosen. The last button is the safe one. */
confirm: (options: {
message: string;
detail?: string;
buttons: string[];
defaultId?: number;
type?: 'none' | 'info' | 'warning' | 'question';
}) => Promise<number>;
}
export interface DesktopClipboardBridge {
/** Puts a file on the clipboard, for pasting into a folder. False when that didn't work. */
copyFile: (name: string, data: ArrayBuffer) => Promise<boolean>;
}
export type DesktopServerPrepareResult =
@@ -83,7 +173,7 @@ export interface DesktopLifecycleBridge {
* Before a window closes or the app quits, the shell asks the page to save what
* it has and waits a few seconds. A window with no handler closes at once.
*/
onFlushRequest: (handler: () => Promise<unknown>) => () => void;
onFlushRequest: (handler: () => Promise<DesktopFlushAnswer | unknown>) => () => void;
}
export type DesktopBrowserSignInResult =
@@ -119,6 +209,8 @@ export interface BulwarkDesktopBridge {
theme?: DesktopThemeBridge;
menu?: DesktopMenuBridge;
shell?: DesktopShellBridge;
dialogs?: DesktopDialogsBridge;
clipboard?: DesktopClipboardBridge;
lifecycle?: DesktopLifecycleBridge;
servers?: DesktopServersBridge;
auth?: DesktopAuthBridge;
+40
View File
@@ -0,0 +1,40 @@
/**
* What a composer comes back as when the app starts again: a pop-out on its draft.
* Each open composer says which draft it holds, as soon as it has one. The shell
* keeps the list per window. Closing a window drops its list, so a draft the user
* put away stays in Drafts and doesn't come back on screen. Quitting keeps it.
*/
import { appPath } from '@/lib/deep-links';
import { getDesktopBridge } from './bridge';
export interface OpenDraft {
draftId: string;
/** The login it was saved through. */
localAccountId?: string | null;
/** The JMAP account holding it, when not that login's own. */
ownerAccountId?: string | null;
}
const open = new Map<string, OpenDraft>();
export function restoreUrlFor(draft: OpenDraft): string {
const search = new URLSearchParams({ draft: draft.draftId });
if (draft.localAccountId) search.set('account', draft.localAccountId);
if (draft.ownerAccountId) search.set('owner', draft.ownerAccountId);
return `${appPath('/popout/compose')}?${search.toString()}`;
}
/** With null, this composer holds no draft any more: sent, discarded or closed. */
export function noteOpenDraft(key: string, draft: OpenDraft | null): void {
const windows = getDesktopBridge()?.windows;
if (!windows?.setRestore) return;
if (draft) open.set(key, draft);
else if (!open.delete(key)) return;
windows.setRestore([...open.values()].map(restoreUrlFor));
}
/** Test helper. */
export function resetOpenDrafts(): void {
open.clear();
}
+60
View File
@@ -0,0 +1,60 @@
import type { ComposerDraftData } from '@/components/email/email-composer';
import { findDraftIdentityIdForLogin, type DraftSource } from '@/lib/draft-source';
import { getDraftComposerBody } from '@/lib/email-body-selection';
import type { Email, Identity } from '@/lib/jmap/types';
import { messagePriorityFromHeaders } from '@/lib/message-priority';
import { reopenedLabelChoice } from '@/lib/outgoing/classification';
/**
* What a composer starts from when a saved draft is opened: from the list, or from
* last time the desktop app was running. `draft` has its full body.
*/
export function composerDataFromDraft(
draft: Email,
{ accountId, localAccountId, identities, noSubject }: DraftSource & { identities: Identity[]; noSubject: string },
): ComposerDraftData {
const { body, plainTextMode } = getDraftComposerBody(draft);
return {
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 !== noSubject ? draft.subject : '',
body,
// Re-open in the format the draft was written in (#1022).
plainTextMode,
showCc: (draft.cc?.length || 0) > 0,
showBcc: (draft.bcc?.length || 0) > 0,
// Restore the identity the draft was composed with: the saved From (name and
// address) against the list the composer renders, so a draft from a non-active
// account, or one of two identities sharing an address, isn't reset to the default.
selectedIdentityId: findDraftIdentityIdForLogin(identities, draft.from?.[0], localAccountId),
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,
})),
};
}
+6
View File
@@ -75,6 +75,12 @@ export function openMessageWindow(target: MessageWindowTarget): boolean {
return focused(openWindow(messagePopoutUrl(target), `bulwark-message-${target.emailId.replace(/\W/g, '_')}`, 'message'));
}
/** A new message in its own window, with nothing to hand over. */
export function openNewComposerWindow(localAccountId?: string | null): boolean {
const name = `bulwark-compose-new-${Date.now().toString(36)}`;
return focused(openWindow(appPath('/popout/compose') + query({ account: localAccountId }), name, 'compose'));
}
export interface ComposerWindow {
/** Hands the draft over. True once the composer is on screen there; until then the sender keeps its copy. */
handOver(payload: ComposePopoutPayload): Promise<boolean>;
+9 -1
View File
@@ -7,6 +7,7 @@
* stores/auth-store.ts and stores/account-store.ts ask here.
*/
import { getDesktopBridge } from '@/lib/desktop/bridge';
import { withBasePath } from '@/lib/browser-navigation';
import { locales } from '@/i18n/routing';
@@ -28,7 +29,7 @@ export function isPopoutPath(pathname: string): boolean {
if (before.length > 0 && !(locales as readonly string[]).includes(before[before.length - 1])) return false;
if (before.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');
return (rest.length === 1 && (rest[0] === 'compose' || rest[0] === 'spare')) || (rest.length === 2 && rest[0] === 'message');
}
export function isPopoutWindow(): boolean {
@@ -61,6 +62,13 @@ export function mainWindowStorage(): Pick<Storage, 'getItem' | 'setItem' | 'remo
*/
export function closePopoutWindow(): void {
if (typeof window === 'undefined') return;
// The desktop app closes it for us: however the window came to be, and past the
// shell's own bookkeeping. Whoever calls this has saved or discarded by now.
const closeSelf = getDesktopBridge()?.windows?.closeSelf;
if (closeSelf) {
closeSelf();
return;
}
window.close();
window.setTimeout(() => {
if (!window.closed) window.location.replace(withBasePath('/'));
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "تم نقل هذه الرسالة إلى {folder}.",
"window_not_ready": "لم تفتح النافذة الجديدة في الوقت المناسب. تبقى مسودتك هنا."
},
"desktopUi": {
"settings": {
"title": "النوافذ",
"double_click_label": "فتح الرسائل في نافذة جديدة بالنقر المزدوج",
"double_click_description": "النقرة الواحدة تفتح الرسالة بجانب القائمة كالمعتاد.",
"restore_label": "إعادة فتح النوافذ عند البدء",
"restore_description": "تعود نوافذ الرسائل والمسودات التي كانت مفتوحة عند الخروج."
},
"attachment": {
"open": "فتح",
"save_as": "حفظ باسم…",
"copy": "نسخ",
"copy_failed": "تعذّر نسخ الملف."
}
},
"desktop_sign_in": {
"browser_button": "سجّل الدخول باستخدام المتصفح",
"browser_waiting": "أكمل تسجيل الدخول في المتصفح",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "Aquest missatge s’ha mogut a {folder}.",
"window_not_ready": "La finestra nova no s’ha obert a temps. L’esborrany es queda aquí."
},
"desktopUi": {
"settings": {
"title": "Finestres",
"double_click_label": "Obre els missatges en una finestra nova amb doble clic",
"double_click_description": "Un sol clic continua obrint el missatge al costat de la llista.",
"restore_label": "Torna a obrir les finestres en iniciar",
"restore_description": "Les finestres de missatges i esborranys obertes en sortir tornen a aparèixer."
},
"attachment": {
"open": "Obre",
"save_as": "Desa com a…",
"copy": "Copia",
"copy_failed": "No s’ha pogut copiar el fitxer."
}
},
"desktop_sign_in": {
"browser_button": "Inicia la sessió amb el navegador",
"browser_waiting": "Acaba d'iniciar la sessió al navegador",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "Tato zpráva byla přesunuta do {folder}.",
"window_not_ready": "Nové okno se neotevřelo včas. Koncept zůstává zde."
},
"desktopUi": {
"settings": {
"title": "Okna",
"double_click_label": "Otevírat zprávy v novém okně dvojitým kliknutím",
"double_click_description": "Jedním kliknutím se zpráva dál otevře vedle seznamu.",
"restore_label": "Při spuštění znovu otevřít okna",
"restore_description": "Okna zpráv a konceptů otevřená při ukončení se znovu otevřou."
},
"attachment": {
"open": "Otevřít",
"save_as": "Uložit jako…",
"copy": "Kopírovat",
"copy_failed": "Soubor se nepodařilo zkopírovat."
}
},
"desktop_sign_in": {
"browser_button": "Přihlásit se v prohlížeči",
"browser_waiting": "Dokončete přihlášení v prohlížeči",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "Denne besked er flyttet til {folder}.",
"window_not_ready": "Det nye vindue åbnede ikke i tide. Din kladde bliver her."
},
"desktopUi": {
"settings": {
"title": "Vinduer",
"double_click_label": "Åbn beskeder i et nyt vindue ved dobbeltklik",
"double_click_description": "Et enkelt klik åbner stadig beskeden ved siden af listen.",
"restore_label": "Åbn vinduer igen ved start",
"restore_description": "Besked- og kladdevinduer, der var åbne, da du lukkede, kommer tilbage."
},
"attachment": {
"open": "Åbn",
"save_as": "Gem som…",
"copy": "Kopiér",
"copy_failed": "Filen kunne ikke kopieres."
}
},
"desktop_sign_in": {
"browser_button": "Log ind med din browser",
"browser_waiting": "Gør login færdigt i din browser",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "Diese Nachricht wurde nach {folder} verschoben.",
"window_not_ready": "Das neue Fenster wurde nicht rechtzeitig geöffnet. Ihr Entwurf bleibt hier."
},
"desktopUi": {
"settings": {
"title": "Fenster",
"double_click_label": "Nachrichten per Doppelklick in einem neuen Fenster öffnen",
"double_click_description": "Ein einfacher Klick öffnet die Nachricht weiterhin neben der Liste.",
"restore_label": "Fenster beim Start wieder öffnen",
"restore_description": "Nachrichten- und Entwurfsfenster, die beim Beenden offen waren, werden wieder geöffnet."
},
"attachment": {
"open": "Öffnen",
"save_as": "Speichern unter…",
"copy": "Kopieren",
"copy_failed": "Die Datei konnte nicht kopiert werden."
}
},
"desktop_sign_in": {
"browser_button": "Mit dem Browser anmelden",
"browser_waiting": "Schließen Sie die Anmeldung im Browser ab",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "This message was moved to {folder}.",
"window_not_ready": "The new window did not open in time. Your draft stays here."
},
"desktopUi": {
"settings": {
"title": "Windows",
"double_click_label": "Open messages in a new window on double-click",
"double_click_description": "A single click still opens the message next to the list.",
"restore_label": "Reopen windows at start",
"restore_description": "Message and draft windows that were open when you quit come back."
},
"attachment": {
"open": "Open",
"save_as": "Save as…",
"copy": "Copy",
"copy_failed": "The file could not be copied."
}
},
"desktop_sign_in": {
"browser_button": "Sign in with your browser",
"browser_waiting": "Finish signing in in your browser",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "Este mensaje se movió a {folder}.",
"window_not_ready": "La ventana nueva no se abrió a tiempo. Tu borrador se queda aquí."
},
"desktopUi": {
"settings": {
"title": "Ventanas",
"double_click_label": "Abrir mensajes en una ventana nueva con doble clic",
"double_click_description": "Un solo clic sigue abriendo el mensaje junto a la lista.",
"restore_label": "Volver a abrir las ventanas al iniciar",
"restore_description": "Las ventanas de mensajes y borradores abiertas al salir vuelven a aparecer."
},
"attachment": {
"open": "Abrir",
"save_as": "Guardar como…",
"copy": "Copiar",
"copy_failed": "No se pudo copiar el archivo."
}
},
"desktop_sign_in": {
"browser_button": "Iniciar sesión con el navegador",
"browser_waiting": "Termina de iniciar sesión en el navegador",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "See kiri teisaldati kausta {folder}.",
"window_not_ready": "Uus aken ei avanenud õigeks ajaks. Mustand jääb siia."
},
"desktopUi": {
"settings": {
"title": "Aknad",
"double_click_label": "Ava kirjad topeltklõpsuga uues aknas",
"double_click_description": "Üks klõps avab kirja endiselt loendi kõrval.",
"restore_label": "Ava aknad käivitamisel uuesti",
"restore_description": "Kirja- ja mustandiaknad, mis olid väljumisel avatud, tulevad tagasi."
},
"attachment": {
"open": "Ava",
"save_as": "Salvesta nimega…",
"copy": "Kopeeri",
"copy_failed": "Faili ei saanud kopeerida."
}
},
"desktop_sign_in": {
"browser_button": "Logi sisse brauseriga",
"browser_waiting": "Lõpeta sisselogimine brauseris",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "این پیام به {folder} منتقل شد.",
"window_not_ready": "پنجرهٔ جدید به‌موقع باز نشد. پیش‌نویس شما همین‌جا می‌ماند."
},
"desktopUi": {
"settings": {
"title": "پنجره‌ها",
"double_click_label": "باز کردن پیام‌ها در پنجرهٔ جدید با دوبار کلیک",
"double_click_description": "یک کلیک همچنان پیام را کنار فهرست باز می‌کند.",
"restore_label": "باز کردن دوبارهٔ پنجره‌ها هنگام شروع",
"restore_description": "پنجره‌های پیام و پیش‌نویس که هنگام خروج باز بودند برمی‌گردند."
},
"attachment": {
"open": "باز کردن",
"save_as": "ذخیره با نام…",
"copy": "کپی",
"copy_failed": "فایل کپی نشد."
}
},
"desktop_sign_in": {
"browser_button": "ورود با مرورگر",
"browser_waiting": "ورود را در مرورگر کامل کنید",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "Ce message a été déplacé vers {folder}.",
"window_not_ready": "La nouvelle fenêtre ne s’est pas ouverte à temps. Votre brouillon reste ici."
},
"desktopUi": {
"settings": {
"title": "Fenêtres",
"double_click_label": "Ouvrir les messages dans une nouvelle fenêtre par double-clic",
"double_click_description": "Un simple clic ouvre toujours le message à côté de la liste.",
"restore_label": "Rouvrir les fenêtres au démarrage",
"restore_description": "Les fenêtres de messages et de brouillons ouvertes à la fermeture reviennent."
},
"attachment": {
"open": "Ouvrir",
"save_as": "Enregistrer sous…",
"copy": "Copier",
"copy_failed": "Impossible de copier le fichier."
}
},
"desktop_sign_in": {
"browser_button": "Se connecter avec le navigateur",
"browser_waiting": "Terminez la connexion dans votre navigateur",
+15
View File
@@ -6502,6 +6502,21 @@
"message_moved_to": "ההודעה הזו הועברה אל {folder}.",
"window_not_ready": "החלון החדש לא נפתח בזמן. הטיוטה נשארת כאן."
},
"desktopUi": {
"settings": {
"title": "חלונות",
"double_click_label": "פתיחת הודעות בחלון חדש בלחיצה כפולה",
"double_click_description": "לחיצה אחת עדיין פותחת את ההודעה ליד הרשימה.",
"restore_label": "פתיחה מחדש של חלונות בהפעלה",
"restore_description": "חלונות הודעות וטיוטות שהיו פתוחים ביציאה חוזרים."
},
"attachment": {
"open": "פתיחה",
"save_as": "שמירה בשם…",
"copy": "העתקה",
"copy_failed": "לא ניתן היה להעתיק את הקובץ."
}
},
"desktop_sign_in": {
"browser_button": "כניסה באמצעות הדפדפן",
"browser_waiting": "יש להשלים את הכניסה בדפדפן",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "Ezt az üzenetet ide helyezték át: {folder}.",
"window_not_ready": "Az új ablak nem nyílt meg időben. A piszkozat itt marad."
},
"desktopUi": {
"settings": {
"title": "Ablakok",
"double_click_label": "Üzenetek megnyitása új ablakban dupla kattintással",
"double_click_description": "Egy kattintás továbbra is a lista mellett nyitja meg az üzenetet.",
"restore_label": "Ablakok újranyitása indításkor",
"restore_description": "A kilépéskor nyitva lévő üzenet- és piszkozatablakok visszatérnek."
},
"attachment": {
"open": "Megnyitás",
"save_as": "Mentés másként…",
"copy": "Másolás",
"copy_failed": "A fájlt nem sikerült másolni."
}
},
"desktop_sign_in": {
"browser_button": "Bejelentkezés böngészővel",
"browser_waiting": "Fejezze be a bejelentkezést a böngészőben",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "Questo messaggio è stato spostato in {folder}.",
"window_not_ready": "La nuova finestra non si è aperta in tempo. La bozza rimane qui."
},
"desktopUi": {
"settings": {
"title": "Finestre",
"double_click_label": "Apri i messaggi in una nuova finestra con il doppio clic",
"double_click_description": "Un solo clic apre comunque il messaggio accanto all’elenco.",
"restore_label": "Riapri le finestre all’avvio",
"restore_description": "Le finestre di messaggi e bozze aperte all’uscita tornano."
},
"attachment": {
"open": "Apri",
"save_as": "Salva con nome…",
"copy": "Copia",
"copy_failed": "Impossibile copiare il file."
}
},
"desktop_sign_in": {
"browser_button": "Accedi con il browser",
"browser_waiting": "Completa l'accesso nel browser",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "このメッセージは {folder} に移動されました。",
"window_not_ready": "新しいウィンドウが時間内に開きませんでした。下書きはここに残ります。"
},
"desktopUi": {
"settings": {
"title": "ウィンドウ",
"double_click_label": "ダブルクリックでメッセージを新しいウィンドウで開く",
"double_click_description": "シングルクリックでは、これまでどおりリストの横に開きます。",
"restore_label": "起動時にウィンドウを再び開く",
"restore_description": "終了時に開いていたメッセージと下書きのウィンドウが戻ります。"
},
"attachment": {
"open": "開く",
"save_as": "名前を付けて保存…",
"copy": "コピー",
"copy_failed": "ファイルをコピーできませんでした。"
}
},
"desktop_sign_in": {
"browser_button": "ブラウザでサインイン",
"browser_waiting": "ブラウザでサインインを完了してください",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "이 메시지는 {folder}(으)로 이동되었습니다.",
"window_not_ready": "새 창이 제때 열리지 않았습니다. 임시 보관 메일은 여기에 남아 있습니다."
},
"desktopUi": {
"settings": {
"title": "창",
"double_click_label": "두 번 클릭하면 메시지를 새 창에서 열기",
"double_click_description": "한 번 클릭하면 지금처럼 목록 옆에 메시지가 열립니다.",
"restore_label": "시작할 때 창 다시 열기",
"restore_description": "종료할 때 열려 있던 메시지와 임시보관 창이 다시 열립니다."
},
"attachment": {
"open": "열기",
"save_as": "다른 이름으로 저장…",
"copy": "복사",
"copy_failed": "파일을 복사할 수 없습니다."
}
},
"desktop_sign_in": {
"browser_button": "브라우저로 로그인",
"browser_waiting": "브라우저에서 로그인을 마치세요",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "Šis ziņojums tika pārvietots uz {folder}.",
"window_not_ready": "Jaunais logs neatvērās laikus. Melnraksts paliek šeit."
},
"desktopUi": {
"settings": {
"title": "Logi",
"double_click_label": "Atvērt ziņojumus jaunā logā ar dubultklikšķi",
"double_click_description": "Viens klikšķis joprojām atver ziņojumu blakus sarakstam.",
"restore_label": "Startējot atkal atvērt logus",
"restore_description": "Ziņojumu un melnrakstu logi, kas bija atvērti izejot, atgriežas."
},
"attachment": {
"open": "Atvērt",
"save_as": "Saglabāt kā…",
"copy": "Kopēt",
"copy_failed": "Failu neizdevās nokopēt."
}
},
"desktop_sign_in": {
"browser_button": "Pierakstīties ar pārlūku",
"browser_waiting": "Pabeidziet pierakstīšanos pārlūkā",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "Энэ мессежийг {folder} руу зөөсөн.",
"window_not_ready": "Шинэ цонх цагтаа нээгдсэнгүй. Таны ноорог энд үлдэнэ."
},
"desktopUi": {
"settings": {
"title": "Цонх",
"double_click_label": "Давхар товшиход мессежийг шинэ цонхонд нээх",
"double_click_description": "Нэг товшилт мессежийг жагсаалтын хажууд нээсээр байна.",
"restore_label": "Эхлэхэд цонхнуудыг дахин нээх",
"restore_description": "Гарах үед нээлттэй байсан мессеж, ноорог цонхнууд буцаж нээгдэнэ."
},
"attachment": {
"open": "Нээх",
"save_as": "Өөрөөр хадгалах…",
"copy": "Хуулах",
"copy_failed": "Файлыг хуулж чадсангүй."
}
},
"desktop_sign_in": {
"browser_button": "Хөтчөөр нэвтрэх",
"browser_waiting": "Нэвтрэлтээ хөтөч дээр дуусгана уу",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "Denne meldingen ble flyttet til {folder}.",
"window_not_ready": "Det nye vinduet åpnet seg ikke i tide. Utkastet blir her."
},
"desktopUi": {
"settings": {
"title": "Vinduer",
"double_click_label": "Åpne meldinger i et nytt vindu ved dobbeltklikk",
"double_click_description": "Et enkelt klikk åpner fortsatt meldingen ved siden av listen.",
"restore_label": "Åpne vinduer på nytt ved start",
"restore_description": "Meldings- og utkastvinduer som var åpne da du avsluttet, kommer tilbake."
},
"attachment": {
"open": "Åpne",
"save_as": "Lagre som…",
"copy": "Kopier",
"copy_failed": "Filen kunne ikke kopieres."
}
},
"desktop_sign_in": {
"browser_button": "Logg på med nettleseren",
"browser_waiting": "Fullfør påloggingen i nettleseren",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "Dit bericht is verplaatst naar {folder}.",
"window_not_ready": "Het nieuwe venster is niet op tijd geopend. Je concept blijft hier."
},
"desktopUi": {
"settings": {
"title": "Vensters",
"double_click_label": "Berichten openen in een nieuw venster met dubbelklikken",
"double_click_description": "Eén klik opent het bericht nog steeds naast de lijst.",
"restore_label": "Vensters opnieuw openen bij het starten",
"restore_description": "Bericht- en conceptvensters die open waren bij het afsluiten, komen terug."
},
"attachment": {
"open": "Openen",
"save_as": "Opslaan als…",
"copy": "Kopiëren",
"copy_failed": "Het bestand kon niet worden gekopieerd."
}
},
"desktop_sign_in": {
"browser_button": "Inloggen met je browser",
"browser_waiting": "Rond het inloggen af in je browser",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "Ta wiadomość została przeniesiona do {folder}.",
"window_not_ready": "Nowe okno nie otworzyło się na czas. Twój szkic zostaje tutaj."
},
"desktopUi": {
"settings": {
"title": "Okna",
"double_click_label": "Otwieraj wiadomości w nowym oknie po dwukrotnym kliknięciu",
"double_click_description": "Pojedyncze kliknięcie nadal otwiera wiadomość obok listy.",
"restore_label": "Otwieraj okna ponownie przy starcie",
"restore_description": "Okna wiadomości i wersji roboczych otwarte przy zamykaniu wrócą."
},
"attachment": {
"open": "Otwórz",
"save_as": "Zapisz jako…",
"copy": "Kopiuj",
"copy_failed": "Nie udało się skopiować pliku."
}
},
"desktop_sign_in": {
"browser_button": "Zaloguj się w przeglądarce",
"browser_waiting": "Dokończ logowanie w przeglądarce",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "Esta mensagem foi movida para {folder}.",
"window_not_ready": "A nova janela não abriu a tempo. O seu rascunho fica aqui."
},
"desktopUi": {
"settings": {
"title": "Janelas",
"double_click_label": "Abrir mensagens numa nova janela com duplo clique",
"double_click_description": "Um único clique continua a abrir a mensagem junto à lista.",
"restore_label": "Reabrir janelas ao iniciar",
"restore_description": "As janelas de mensagens e rascunhos abertas ao sair voltam a aparecer."
},
"attachment": {
"open": "Abrir",
"save_as": "Guardar como…",
"copy": "Copiar",
"copy_failed": "Não foi possível copiar o ficheiro."
}
},
"desktop_sign_in": {
"browser_button": "Entrar com o navegador",
"browser_waiting": "Termine de entrar no navegador",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "Acest mesaj a fost mutat în {folder}.",
"window_not_ready": "Fereastra nouă nu s-a deschis la timp. Ciorna rămâne aici."
},
"desktopUi": {
"settings": {
"title": "Ferestre",
"double_click_label": "Deschide mesajele într-o fereastră nouă la dublu clic",
"double_click_description": "Un singur clic deschide în continuare mesajul lângă listă.",
"restore_label": "Redeschide ferestrele la pornire",
"restore_description": "Ferestrele de mesaje și ciorne deschise la ieșire revin."
},
"attachment": {
"open": "Deschide",
"save_as": "Salvează ca…",
"copy": "Copiază",
"copy_failed": "Fișierul nu a putut fi copiat."
}
},
"desktop_sign_in": {
"browser_button": "Conectează-te cu browserul",
"browser_waiting": "Finalizează conectarea în browser",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "Это письмо перемещено в {folder}.",
"window_not_ready": "Новое окно не открылось вовремя. Черновик остаётся здесь."
},
"desktopUi": {
"settings": {
"title": "Окна",
"double_click_label": "Открывать письма в новом окне двойным щелчком",
"double_click_description": "Одиночный щелчок по-прежнему открывает письмо рядом со списком.",
"restore_label": "Снова открывать окна при запуске",
"restore_description": "Окна писем и черновиков, открытые при выходе, вернутся."
},
"attachment": {
"open": "Открыть",
"save_as": "Сохранить как…",
"copy": "Копировать",
"copy_failed": "Не удалось скопировать файл."
}
},
"desktop_sign_in": {
"browser_button": "Войти через браузер",
"browser_waiting": "Завершите вход в браузере",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "Táto správa bola presunutá do {folder}.",
"window_not_ready": "Nové okno sa neotvorilo včas. Koncept zostáva tu."
},
"desktopUi": {
"settings": {
"title": "Okná",
"double_click_label": "Otvárať správy v novom okne dvojitým kliknutím",
"double_click_description": "Jedným kliknutím sa správa stále otvorí vedľa zoznamu.",
"restore_label": "Pri spustení znova otvoriť okná",
"restore_description": "Okná správ a konceptov otvorené pri ukončení sa vrátia."
},
"attachment": {
"open": "Otvoriť",
"save_as": "Uložiť ako…",
"copy": "Kopírovať",
"copy_failed": "Súbor sa nepodarilo skopírovať."
}
},
"desktop_sign_in": {
"browser_button": "Prihlásiť sa v prehliadači",
"browser_waiting": "Dokončite prihlásenie v prehliadači",
+15
View File
@@ -6509,6 +6509,21 @@
"message_moved_to": "ข้อความนี้ถูกย้ายไปยัง {folder}",
"window_not_ready": "หน้าต่างใหม่เปิดไม่ทัน แบบร่างของคุณยังอยู่ที่นี่"
},
"desktopUi": {
"settings": {
"title": "หน้าต่าง",
"double_click_label": "เปิดข้อความในหน้าต่างใหม่เมื่อดับเบิลคลิก",
"double_click_description": "คลิกครั้งเดียวยังคงเปิดข้อความข้างรายการ",
"restore_label": "เปิดหน้าต่างอีกครั้งเมื่อเริ่มต้น",
"restore_description": "หน้าต่างข้อความและฉบับร่างที่เปิดอยู่ตอนออกจะกลับมา"
},
"attachment": {
"open": "เปิด",
"save_as": "บันทึกเป็น…",
"copy": "คัดลอก",
"copy_failed": "คัดลอกไฟล์ไม่ได้"
}
},
"desktop_sign_in": {
"browser_button": "ลงชื่อเข้าใช้ด้วยเบราว์เซอร์",
"browser_waiting": "ลงชื่อเข้าใช้ให้เสร็จในเบราว์เซอร์",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "Bu ileti {folder} klasörüne taşındı.",
"window_not_ready": "Yeni pencere zamanında açılmadı. Taslağınız burada kalıyor."
},
"desktopUi": {
"settings": {
"title": "Pencereler",
"double_click_label": "İletileri çift tıklamayla yeni pencerede aç",
"double_click_description": "Tek tıklama iletiyi yine listenin yanında açar.",
"restore_label": "Başlangıçta pencereleri yeniden aç",
"restore_description": "Çıkarken açık olan ileti ve taslak pencereleri geri gelir."
},
"attachment": {
"open": "Aç",
"save_as": "Farklı kaydet…",
"copy": "Kopyala",
"copy_failed": "Dosya kopyalanamadı."
}
},
"desktop_sign_in": {
"browser_button": "Tarayıcıyla oturum aç",
"browser_waiting": "Oturum açmayı tarayıcınızda tamamlayın",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "Цього листа переміщено в {folder}.",
"window_not_ready": "Нове вікно не відкрилося вчасно. Чернетка залишається тут."
},
"desktopUi": {
"settings": {
"title": "Вікна",
"double_click_label": "Відкривати листи в новому вікні подвійним клацанням",
"double_click_description": "Одне клацання, як і раніше, відкриває лист поруч зі списком.",
"restore_label": "Знову відкривати вікна під час запуску",
"restore_description": "Вікна листів і чернеток, відкриті під час виходу, повернуться."
},
"attachment": {
"open": "Відкрити",
"save_as": "Зберегти як…",
"copy": "Копіювати",
"copy_failed": "Не вдалося скопіювати файл."
}
},
"desktop_sign_in": {
"browser_button": "Увійти через браузер",
"browser_waiting": "Завершіть вхід у браузері",
+15
View File
@@ -6513,6 +6513,21 @@
"message_moved_to": "Thư này đã được chuyển đến {folder}.",
"window_not_ready": "Cửa sổ mới không mở kịp. Bản nháp của bạn vẫn ở đây."
},
"desktopUi": {
"settings": {
"title": "Cửa sổ",
"double_click_label": "Mở thư trong cửa sổ mới khi nhấp đúp",
"double_click_description": "Nhấp một lần vẫn mở thư bên cạnh danh sách.",
"restore_label": "Mở lại cửa sổ khi khởi động",
"restore_description": "Các cửa sổ thư và bản nháp đang mở khi thoát sẽ trở lại."
},
"attachment": {
"open": "Mở",
"save_as": "Lưu thành…",
"copy": "Sao chép",
"copy_failed": "Không thể sao chép tệp."
}
},
"desktop_sign_in": {
"browser_button": "Đăng nhập bằng trình duyệt",
"browser_waiting": "Hoàn tất đăng nhập trong trình duyệt",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "這封郵件已移到 {folder}。",
"window_not_ready": "新視窗未能及時開啟。你的草稿仍保留在這裡。"
},
"desktopUi": {
"settings": {
"title": "視窗",
"double_click_label": "按兩下時在新視窗中開啟郵件",
"double_click_description": "按一下仍會在清單旁開啟郵件。",
"restore_label": "啟動時重新開啟視窗",
"restore_description": "結束時開啟的郵件和草稿視窗會恢復。"
},
"attachment": {
"open": "開啟",
"save_as": "另存新檔…",
"copy": "複製",
"copy_failed": "無法複製檔案。"
}
},
"desktop_sign_in": {
"browser_button": "使用瀏覽器登入",
"browser_waiting": "請在瀏覽器中完成登入",
+15
View File
@@ -6501,6 +6501,21 @@
"message_moved_to": "这封邮件已移动到 {folder}。",
"window_not_ready": "新窗口未能及时打开。你的草稿仍保留在这里。"
},
"desktopUi": {
"settings": {
"title": "窗口",
"double_click_label": "双击时在新窗口中打开邮件",
"double_click_description": "单击仍会在列表旁打开邮件。",
"restore_label": "启动时重新打开窗口",
"restore_description": "退出时打开的邮件和草稿窗口会恢复。"
},
"attachment": {
"open": "打开",
"save_as": "另存为…",
"copy": "复制",
"copy_failed": "无法复制文件。"
}
},
"desktop_sign_in": {
"browser_button": "使用浏览器登录",
"browser_waiting": "请在浏览器中完成登录",