mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
Merge branch 'feat/do-native-ui' into feat/do-app-lock
This commit is contained in:
@@ -37,6 +37,7 @@ import { FreeBusyAutoShare } from "@/components/calendar/free-busy-auto-share";
|
||||
import { MainWindowOnly } from "@/components/popout/main-window-only";
|
||||
import { PopoutHost } from "@/components/popout/popout-host";
|
||||
import { WindowSyncHost } from "@/components/popout/window-sync-host";
|
||||
import { DesktopHost } from "@/components/desktop/desktop-host";
|
||||
import { IS_LITE } from "@/lib/lite";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { locales } from "@/i18n/routing";
|
||||
@@ -89,6 +90,7 @@ export default async function LocaleLayout({
|
||||
{!IS_LITE && <PopoutHost />}
|
||||
</MainWindowOnly>
|
||||
{!IS_LITE && <WindowSyncHost />}
|
||||
{!IS_LITE && <DesktopHost />}
|
||||
<SessionTimeoutGuard />
|
||||
<AccountTemplateSync />
|
||||
{children}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -587,6 +587,11 @@ optgroup {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.window-title-bar-item {
|
||||
app-region: no-drag;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* Safe area insets for notched devices (iPhone X+, etc.) */
|
||||
.safe-area-inset-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { BulwarkDesktopBridge } from '@/lib/desktop/types';
|
||||
import { registerDesktopSurface, resetDesktopCommands } from '@/lib/desktop/commands';
|
||||
import { resetDesktopPrefsCache, setDesktopPref } from '@/lib/desktop/preferences';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import { useOfflineStore } from '@/lib/offline/state';
|
||||
|
||||
const push = vi.fn();
|
||||
vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push }) }));
|
||||
|
||||
const canPrint = vi.fn(() => true);
|
||||
const canDownload = vi.fn(() => true);
|
||||
vi.mock('@/lib/device-policy', () => ({ canPrintAnywhere: () => canPrint(), canDownloadAnywhere: () => canDownload() }));
|
||||
|
||||
const { DesktopHost } = await import('../desktop-host');
|
||||
|
||||
function fakeBridge() {
|
||||
let command: ((id: string) => void) | null = null;
|
||||
let flush: (() => Promise<unknown>) | null = null;
|
||||
const parts = {
|
||||
setState: vi.fn(),
|
||||
setLanguages: vi.fn(),
|
||||
sessionReady: vi.fn(),
|
||||
setRestoreEnabled: vi.fn(),
|
||||
setTheme: vi.fn(),
|
||||
};
|
||||
const bridge = {
|
||||
version: 1,
|
||||
platform: 'win32',
|
||||
menu: { setState: parts.setState, onCommand: (listener: (id: string) => void) => { command = listener; return () => {}; } },
|
||||
shell: { openExternal: () => {}, setLanguages: parts.setLanguages },
|
||||
windows: { sessionReady: parts.sessionReady, setRestoreEnabled: parts.setRestoreEnabled },
|
||||
theme: { set: parts.setTheme, system: async () => ({ dark: false, highContrast: false }) },
|
||||
lifecycle: { onFlushRequest: (handler: () => Promise<unknown>) => { flush = handler; return () => {}; } },
|
||||
};
|
||||
window.bulwarkDesktop = bridge as unknown as BulwarkDesktopBridge;
|
||||
return { ...parts, run: (id: string) => act(() => command!(id)), flush: () => flush!() };
|
||||
}
|
||||
|
||||
const original = useAuthStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
push.mockClear();
|
||||
canPrint.mockReturnValue(true);
|
||||
canDownload.mockReturnValue(true);
|
||||
localStorage.clear();
|
||||
resetDesktopPrefsCache();
|
||||
useAuthStore.setState({ isAuthenticated: true, getAllConnectedClients: () => new Map() } as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.bulwarkDesktop = undefined;
|
||||
resetDesktopCommands();
|
||||
useAuthStore.setState(original);
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
describe('DesktopHost', () => {
|
||||
it('does nothing in a browser', () => {
|
||||
expect(() => render(<DesktopHost />)).not.toThrow();
|
||||
});
|
||||
|
||||
it('runs a command from what\'s on the page first', () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
const onPage = vi.fn();
|
||||
registerDesktopSurface({ handlers: { 'go.inbox': onPage }, state: {} });
|
||||
shell.run('go.inbox');
|
||||
expect(onPage).toHaveBeenCalledTimes(1);
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('with nothing there, the commands that work anywhere still do', () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
shell.run('go.calendar');
|
||||
expect(push).toHaveBeenLastCalledWith('/calendar');
|
||||
shell.run('go.contacts');
|
||||
expect(push).toHaveBeenLastCalledWith('/contacts');
|
||||
});
|
||||
|
||||
it('a link to a folder goes through the router, encoded', () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
shell.run('link.folder:sent');
|
||||
expect(push).toHaveBeenLastCalledWith('/mail/folder/sent');
|
||||
});
|
||||
|
||||
it('an unknown command does nothing', () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
expect(() => shell.run('nonsense')).not.toThrow();
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tells the shell what the organisation allows', async () => {
|
||||
canPrint.mockReturnValue(false);
|
||||
canDownload.mockReturnValue(false);
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
await waitFor(() => expect(shell.setState).toHaveBeenCalled());
|
||||
expect(shell.setState.mock.lastCall![0]).toMatchObject({ 'file.print': false, 'file.savePdf': false, 'policy.download': false });
|
||||
});
|
||||
|
||||
it('signed in: it says so once, for windows from last time and waiting links', async () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
await waitFor(() => expect(shell.sessionReady).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('not signed in, or in a pop-out with a borrowed session: it doesn\'t', async () => {
|
||||
const shell = fakeBridge();
|
||||
useAuthStore.setState({ isAuthenticated: false } as never);
|
||||
const first = render(<DesktopHost />);
|
||||
await act(async () => {});
|
||||
expect(shell.sessionReady).not.toHaveBeenCalled();
|
||||
first.unmount();
|
||||
useAuthStore.setState({ isAuthenticated: true } as never);
|
||||
window.history.replaceState(null, '', '/popout/compose');
|
||||
render(<DesktopHost />);
|
||||
await act(async () => {});
|
||||
expect(shell.sessionReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the restore setting on', async () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
await waitFor(() => expect(shell.setRestoreEnabled).toHaveBeenLastCalledWith(true));
|
||||
act(() => setDesktopPref('restoreWindows', false));
|
||||
await waitFor(() => expect(shell.setRestoreEnabled).toHaveBeenLastCalledWith(false));
|
||||
});
|
||||
|
||||
it('says which languages are in use', async () => {
|
||||
const shell = fakeBridge();
|
||||
act(() => useLocaleStore.setState({ locale: 'de' }));
|
||||
render(<DesktopHost />);
|
||||
await waitFor(() => expect(shell.setLanguages).toHaveBeenCalled());
|
||||
expect(shell.setLanguages.mock.lastCall![0]).toMatchObject({ ui: 'de' });
|
||||
});
|
||||
|
||||
it('a quit is told how much mail is waiting', async () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
act(() => useOfflineStore.setState({ outbox: [{ id: 1 }, { id: 2 }] } as never));
|
||||
await expect(shell.flush()).resolves.toEqual({ unsent: 2 });
|
||||
act(() => useOfflineStore.setState({ outbox: [] } as never));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { openKeyboardShortcuts, parkAppCommand, requestAppCommand, type AppCommand } from "@/lib/app-commands";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import { runDesktopCommand, setDesktopBaseState } from "@/lib/desktop/commands";
|
||||
import { useDesktopPrefs } from "@/lib/desktop/preferences";
|
||||
import { applySystemAccent } from "@/lib/desktop/system-accent";
|
||||
import type { DesktopSystemTheme } from "@/lib/desktop/types";
|
||||
import { canDownloadAnywhere, canPrintAnywhere } from "@/lib/device-policy";
|
||||
import { useOfflineStore } from "@/lib/offline/state";
|
||||
import { openNewComposerWindow } from "@/lib/popout/open";
|
||||
import { isPopoutWindow } from "@/lib/popout/window-role";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useLocaleStore } from "@/stores/locale-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
|
||||
/**
|
||||
* The webmail's side of what every desktop window needs: runs menu commands nothing
|
||||
* else on the page took, tells the shell the language and the device policy, and
|
||||
* says how much mail is waiting when the app quits. Renders nothing and does nothing
|
||||
* in a browser, where there's no bridge.
|
||||
*/
|
||||
export function DesktopHost() {
|
||||
const router = useRouter();
|
||||
const routerRef = useRef(router);
|
||||
routerRef.current = router;
|
||||
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const singleKeys = useSettingsStore((s) => s.singleKeyShortcuts);
|
||||
const writingLanguage = useSettingsStore((s) => s.writingLanguage);
|
||||
const uiLocale = useLocaleStore((s) => s.locale);
|
||||
const policyLoaded = usePolicyStore((s) => s.loaded);
|
||||
const accounts = useAuthStore((s) => s.getAllConnectedClients().size);
|
||||
const { restoreWindows } = useDesktopPrefs();
|
||||
const theme = useThemeStore((s) => s.theme);
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const activeThemeId = useThemeStore((s) => s.activeThemeId);
|
||||
|
||||
// Commands no surface on the page answers: those work from anywhere in the app.
|
||||
useEffect(() => {
|
||||
const menu = getDesktopBridge()?.menu;
|
||||
if (!menu?.onCommand) return;
|
||||
const toMail = (command: AppCommand) => {
|
||||
if (requestAppCommand(command)) return;
|
||||
parkAppCommand(command);
|
||||
routerRef.current.push("/");
|
||||
};
|
||||
const fallback: Record<string, () => void> = {
|
||||
"file.newMessage": () => {
|
||||
if (isPopoutWindow()) openNewComposerWindow(useAuthStore.getState().activeAccountId);
|
||||
else toMail("compose");
|
||||
},
|
||||
"edit.search": () => toMail("search"),
|
||||
"go.inbox": () => routerRef.current.push("/"),
|
||||
"go.mail": () => routerRef.current.push("/"),
|
||||
"go.calendar": () => routerRef.current.push("/calendar"),
|
||||
"go.contacts": () => routerRef.current.push("/contacts"),
|
||||
"go.files": () => routerRef.current.push("/files"),
|
||||
"help.shortcuts": () => openKeyboardShortcuts(),
|
||||
};
|
||||
return menu.onCommand((id) => {
|
||||
// A bulwark:// link to a folder.
|
||||
if (id.startsWith("link.folder:")) {
|
||||
routerRef.current.push(`/mail/folder/${encodeURIComponent(id.slice("link.folder:".length))}`);
|
||||
return;
|
||||
}
|
||||
if (!runDesktopCommand(id)) fallback[id]?.();
|
||||
});
|
||||
}, []);
|
||||
|
||||
// What's on offer anywhere, and what the organisation allows.
|
||||
useEffect(() => {
|
||||
if (!getDesktopBridge()?.menu) return;
|
||||
const canPrint = canPrintAnywhere();
|
||||
const signedIn = isAuthenticated;
|
||||
const popout = isPopoutWindow();
|
||||
setDesktopBaseState({
|
||||
"shortcuts.singleKeys": singleKeys,
|
||||
"policy.download": canDownloadAnywhere(),
|
||||
"file.print": canPrint,
|
||||
"file.savePdf": canPrint,
|
||||
"file.newMessage": signedIn,
|
||||
"edit.search": signedIn && !popout,
|
||||
"go.inbox": signedIn,
|
||||
"go.mail": signedIn,
|
||||
"go.calendar": signedIn,
|
||||
"go.contacts": signedIn,
|
||||
"go.files": signedIn,
|
||||
"help.shortcuts": true,
|
||||
});
|
||||
}, [isAuthenticated, singleKeys, policyLoaded, accounts]);
|
||||
|
||||
// Menus and spellcheck follow the interface, and the composer's language too.
|
||||
useEffect(() => {
|
||||
const shell = getDesktopBridge()?.shell;
|
||||
if (!shell?.setLanguages) return;
|
||||
const ui = uiLocale || document.documentElement.lang;
|
||||
if (!ui) return;
|
||||
const composer = writingLanguage && writingLanguage !== "auto" ? [writingLanguage] : [];
|
||||
shell.setLanguages({ ui, composer });
|
||||
}, [uiLocale, writingLanguage]);
|
||||
|
||||
// The windows' behaviour lives here, the shell only needs its copy.
|
||||
useEffect(() => {
|
||||
getDesktopBridge()?.windows?.setRestoreEnabled?.(restoreWindows);
|
||||
}, [restoreWindows]);
|
||||
|
||||
// Signed in: this window has a session to lend, so pop-outs from last time can open.
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || isPopoutWindow()) return;
|
||||
getDesktopBridge()?.windows?.sessionReady?.();
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// A quit asks for this: mail that hasn't left is worth a question.
|
||||
useEffect(() => {
|
||||
const lifecycle = getDesktopBridge()?.lifecycle;
|
||||
if (!lifecycle || isPopoutWindow()) return;
|
||||
return lifecycle.onFlushRequest(async () => ({ unsent: useOfflineStore.getState().outbox.length }));
|
||||
}, []);
|
||||
|
||||
// The OS accent colour, while the theme follows the OS.
|
||||
useEffect(() => {
|
||||
const bridge = getDesktopBridge()?.theme;
|
||||
if (!bridge) return;
|
||||
const followsSystem = theme === "system" && !activeThemeId;
|
||||
const apply = (system: DesktopSystemTheme) => {
|
||||
applySystemAccent({
|
||||
accent: system.accent,
|
||||
highContrast: system.highContrast,
|
||||
dark: document.documentElement.classList.contains("dark"),
|
||||
followsSystem,
|
||||
});
|
||||
};
|
||||
let cancelled = false;
|
||||
void bridge.system?.().then((system) => {
|
||||
if (!cancelled) apply(system);
|
||||
});
|
||||
const stop = bridge.onSystemChange?.(apply);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stop?.();
|
||||
};
|
||||
}, [theme, resolvedTheme, activeThemeId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { ReaderAttachments, splitFileName, type ReaderAttachmentItem } from '../reader-attachments';
|
||||
|
||||
// The reader's attachment bar: equal tiles that keep the extension in view,
|
||||
@@ -24,6 +24,47 @@ function renderBar(attachments: ReaderAttachmentItem[], overrides: Partial<Param
|
||||
return props;
|
||||
}
|
||||
|
||||
describe('the right-click menu in the desktop app', () => {
|
||||
afterEach(() => {
|
||||
window.bulwarkDesktop = undefined;
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const openMenu = async (popupAnswer: (items: { id?: string; label?: string }[]) => string | null, overrides: Partial<Parameters<typeof ReaderAttachments>[0]> = {}) => {
|
||||
const popup = vi.fn(async (items: { id?: string; label?: string }[]) => popupAnswer(items));
|
||||
window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {}, popup } } as never;
|
||||
const props = renderBar([file('1', 'report.pdf')], { onCopyFile: vi.fn(), ...overrides });
|
||||
fireEvent.contextMenu(await screen.findByTestId('attachment'), { clientX: 5, clientY: 5 });
|
||||
await waitFor(() => expect(popup).toHaveBeenCalled());
|
||||
return { props, popup };
|
||||
};
|
||||
|
||||
it('has Open, Save as and Copy, natively', async () => {
|
||||
const { popup } = await openMenu(() => null);
|
||||
expect(popup.mock.calls[0][0].map((item) => item.label)).toEqual(['open', 'save_as', 'copy']);
|
||||
});
|
||||
|
||||
it.each([['open', 'onOpen'], ['save_as', 'onDownload'], ['copy', 'onCopyFile']] as const)('%s runs the action', async (label, handler) => {
|
||||
const { props } = await openMenu((items) => items.find((item) => item.label === label)!.id!);
|
||||
await waitFor(() => expect((props as unknown as Record<string, ReturnType<typeof vi.fn>>)[handler]).toHaveBeenCalledTimes(1));
|
||||
expect((props as unknown as Record<string, ReturnType<typeof vi.fn>>)[handler].mock.calls[0][0]).toMatchObject({ id: '1' });
|
||||
});
|
||||
|
||||
it('with downloads off there\'s nothing to save or copy', async () => {
|
||||
const { popup } = await openMenu(() => null, { downloadsOff: true, opensPreview: () => true });
|
||||
expect(popup.mock.calls[0][0].map((item) => item.label)).toEqual(['open']);
|
||||
});
|
||||
|
||||
it('a browser keeps its own menu', async () => {
|
||||
window.bulwarkDesktop = undefined;
|
||||
renderBar([file('1', 'report.pdf')], { onCopyFile: vi.fn() });
|
||||
const event = fireEvent.contextMenu(await screen.findByTestId('attachment'));
|
||||
// Not prevented: the browser's menu shows, and no HTML one opens.
|
||||
expect(event).toBe(true);
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitFileName', () => {
|
||||
it('splits off a short extension', () => {
|
||||
expect(splitFileName('prototype-v3.png')).toEqual(['prototype-v3', '.png']);
|
||||
|
||||
@@ -169,6 +169,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";
|
||||
|
||||
@@ -1943,6 +1945,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;
|
||||
@@ -3017,6 +3027,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);
|
||||
@@ -4433,6 +4445,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.
|
||||
@@ -4444,8 +4479,7 @@ export function EmailComposer({
|
||||
return;
|
||||
}
|
||||
afterCloseRef.current = null;
|
||||
setCloseDialogKind('discard');
|
||||
setShowCloseDialog(true);
|
||||
openCloseDialog('discard');
|
||||
};
|
||||
|
||||
const handleClose = (afterClose?: () => void) => {
|
||||
@@ -4457,8 +4491,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();
|
||||
}
|
||||
|
||||
@@ -159,6 +159,7 @@ import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { imageBlobUrl, inertBlobType, isFilePreviewable, isMimeTypeSafeForInlinePreview, toInertBlob } from "@/lib/file-preview";
|
||||
import { openPreviewInNewWindow } from "@/lib/standalone-display";
|
||||
import { downloadAllowed, saveBlobAs } from "@/lib/download-guard";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import { canPickFolder, createUniqueNamer, isPickerCancelled, pickFolder, saveFilesToFolder, type FolderHandle } from "@/lib/attachments/save-to-folder";
|
||||
import { useDevicePolicy } from "@/hooks/use-device-policy";
|
||||
import { Watermark, useWatermarkText } from "@/components/device/watermark";
|
||||
@@ -3109,6 +3110,8 @@ export function EmailViewer({
|
||||
|
||||
// What the attachment bar and the attachment cards share.
|
||||
const attachmentCardsRef = useRef<HTMLDivElement>(null);
|
||||
const desktopClipboard = getDesktopBridge()?.clipboard;
|
||||
const tDesktopUi = useTranslations('desktopUi.attachment');
|
||||
const readerAttachmentProps = {
|
||||
attachments: effectiveAttachments,
|
||||
thumbUrls: imageThumbUrls,
|
||||
@@ -3117,6 +3120,13 @@ export function EmailViewer({
|
||||
onDownload: handleEffectiveAttachmentDownload,
|
||||
onDownloadAll: handleDownloadAllAttachments,
|
||||
onSaveAllToFolder: canPickFolder() ? () => void handleSaveAllToFolder() : undefined,
|
||||
onCopyFile: desktopClipboard && blobClient ? (attachment: EffectiveAttachment) => {
|
||||
if (!attachment.blobId || !downloadAllowed('attachment', email)) return;
|
||||
void blobClient.fetchBlobArrayBuffer(attachment.blobId, undefined, undefined, blobAccountId)
|
||||
.then((data) => desktopClipboard.copyFile(resolveAttachmentName(attachment), data))
|
||||
.then((copied) => { if (!copied) toast.error(tDesktopUi('copy_failed')); })
|
||||
.catch(() => toast.error(tDesktopUi('copy_failed')));
|
||||
} : undefined,
|
||||
downloadingAll: isDownloadingAll,
|
||||
downloadsOff,
|
||||
// Save to Files would get around the policy: Files downloads are off too, but the copy would stay.
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type DragEvent, type ReactNode } from "react";
|
||||
import { useEffect, useState, type DragEvent, type ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronDown, ChevronUp, Download, FolderInput, Loader2, Paperclip } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContextMenu, ContextMenuItem } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { nativeMenusAvailable } from "@/lib/desktop/native-menu";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { getAttachmentDisplayName } from "@/lib/attachment-name";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
@@ -47,6 +50,11 @@ interface ReaderAttachmentsProps<T extends ReaderAttachmentItem> {
|
||||
layout?: ReaderAttachmentLayout;
|
||||
/** Object URLs of image attachments, keyed by attachment id. */
|
||||
thumbUrls: Record<string, string>;
|
||||
/**
|
||||
* Copies the file to the clipboard. Comes with the right-click menu the desktop app
|
||||
* has on an attachment; a browser has its own menu there and never gets this one.
|
||||
*/
|
||||
onCopyFile?: (attachment: T) => void;
|
||||
/** Whether a click on the attachment previews it instead of downloading it. */
|
||||
opensPreview: (attachment: T) => boolean;
|
||||
onOpen: (attachment: T) => void;
|
||||
@@ -94,6 +102,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
|
||||
onDownload,
|
||||
onDownloadAll,
|
||||
onSaveAllToFolder,
|
||||
onCopyFile,
|
||||
downloadingAll,
|
||||
downloadsOff = false,
|
||||
onSaveToFiles,
|
||||
@@ -105,6 +114,11 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
|
||||
const tFiles = useTranslations("files");
|
||||
const tDevice = useTranslations("device_policy");
|
||||
const tWindows = useTranslations("pwaWindows");
|
||||
const tDesktop = useTranslations("desktopUi.attachment");
|
||||
const menu = useContextMenu<T>();
|
||||
const [desktopMenu, setDesktopMenu] = useState(false);
|
||||
useEffect(() => setDesktopMenu(nativeMenusAvailable()), []);
|
||||
const menuTarget = menu.contextMenu.data;
|
||||
// Opens nothing: no preview, and downloads are off.
|
||||
const locked = (attachment: T) => downloadsOff && !opensPreview(attachment);
|
||||
const openLabel = (attachment: T, name: string) =>
|
||||
@@ -292,7 +306,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
|
||||
layout === "cards" ? "grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] gap-3" : "grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-2",
|
||||
)}>
|
||||
{shown.map((attachment) => (
|
||||
<div key={attachment.id} className="flex min-w-0 flex-col gap-1">
|
||||
<div key={attachment.id} className="flex min-w-0 flex-col gap-1" onContextMenu={desktopMenu ? (event) => menu.openContextMenu(event, attachment) : undefined}>
|
||||
{renderItem
|
||||
? renderItem(attachment, (drag) => draw(attachment, drag))
|
||||
: draw(attachment, NO_DRAG)}
|
||||
@@ -324,6 +338,19 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{desktopMenu && menuTarget && (
|
||||
<ContextMenu ref={menu.menuRef} isOpen={menu.contextMenu.isOpen} position={menu.contextMenu.position} onClose={menu.closeContextMenu}>
|
||||
{!locked(menuTarget) && (
|
||||
<ContextMenuItem label={tDesktop("open")} onClick={() => { menu.closeContextMenu(); onOpen(menuTarget); }} />
|
||||
)}
|
||||
{!downloadsOff && (
|
||||
<ContextMenuItem label={tDesktop("save_as")} onClick={() => { menu.closeContextMenu(); onDownload(menuTarget); }} />
|
||||
)}
|
||||
{!downloadsOff && onCopyFile && (
|
||||
<ContextMenuItem label={tDesktop("copy")} onClick={() => { menu.closeContextMenu(); onCopyFile(menuTarget); }} />
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import type { DesktopMenuBarEntry } from "@/lib/desktop/types";
|
||||
import { applyTitlebarInset, watchTitlebarArea, type TitlebarArea } from "@/lib/window-controls-overlay";
|
||||
|
||||
/**
|
||||
@@ -12,10 +14,52 @@ import { applyTitlebarInset, watchTitlebarArea, type TitlebarArea } from "@/lib/
|
||||
* Renders nothing, and changes nothing on the page, without an overlay. It
|
||||
* repeats the title for sighted users only, so it stays out of the
|
||||
* accessibility tree and out of the tab order.
|
||||
*
|
||||
* In the desktop app on Windows, Alt swaps the title for the menu's names, since a
|
||||
* window without a native frame has no native bar. Each name opens the app's menu
|
||||
* as a native one (src/main/menu.ts in the desktop repo).
|
||||
*/
|
||||
export function WindowTitleBar() {
|
||||
const [area, setArea] = useState<TitlebarArea | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [entries, setEntries] = useState<DesktopMenuBarEntry[] | null>(null);
|
||||
const stripRef = useRef<HTMLDivElement>(null);
|
||||
const previousFocus = useRef<HTMLElement | null>(null);
|
||||
// What to do once the names are on screen: open that menu, or null to focus the first name.
|
||||
const afterShow = useRef<string | null | undefined>(undefined);
|
||||
|
||||
const openMenu = useCallback((id: string) => {
|
||||
const button = stripRef.current?.querySelector<HTMLElement>(`[data-menu-id="${CSS.escape(id)}"]`);
|
||||
if (!button) return;
|
||||
const rect = button.getBoundingClientRect();
|
||||
getDesktopBridge()?.menu?.bar?.open(id, { x: rect.left, y: rect.bottom });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const bar = getDesktopBridge()?.menu?.bar;
|
||||
if (!bar) return;
|
||||
return bar.onEvent((event) => {
|
||||
if (!event.visible) {
|
||||
setEntries(null);
|
||||
previousFocus.current?.focus?.();
|
||||
previousFocus.current = null;
|
||||
return;
|
||||
}
|
||||
void bar.entries().then((list) => {
|
||||
previousFocus.current ??= document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
afterShow.current = event.open ?? null;
|
||||
setEntries(list);
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const next = afterShow.current;
|
||||
if (!entries || next === undefined) return;
|
||||
afterShow.current = undefined;
|
||||
if (next) openMenu(next);
|
||||
else stripRef.current?.querySelector<HTMLElement>("button")?.focus();
|
||||
}, [entries, openMenu]);
|
||||
|
||||
useEffect(() => watchTitlebarArea((next) => {
|
||||
applyTitlebarInset(next);
|
||||
@@ -35,6 +79,52 @@ export function WindowTitleBar() {
|
||||
|
||||
if (!area) return null;
|
||||
|
||||
const onStripKey = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const buttons = Array.from(stripRef.current?.querySelectorAll<HTMLElement>("button") ?? []);
|
||||
const at = buttons.indexOf(document.activeElement as HTMLElement);
|
||||
if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
const delta = event.key === "ArrowRight" ? 1 : -1;
|
||||
buttons[(at + delta + buttons.length) % buttons.length]?.focus();
|
||||
} else if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
buttons[at]?.click();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setEntries(null);
|
||||
previousFocus.current?.focus?.();
|
||||
previousFocus.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (entries) {
|
||||
return (
|
||||
<div
|
||||
ref={stripRef}
|
||||
role="menubar"
|
||||
data-window-title-bar=""
|
||||
data-window-menu-bar=""
|
||||
onKeyDown={onStripKey}
|
||||
className="window-title-bar fixed z-[100002] flex items-center gap-0.5 bg-background ps-1 pe-3 text-xs text-foreground select-none"
|
||||
style={{ top: area.y, left: area.x, width: area.width, height: area.height }}
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-haspopup="menu"
|
||||
data-menu-id={entry.id}
|
||||
onClick={() => openMenu(entry.id)}
|
||||
className="window-title-bar-item rounded px-2 py-1 hover:bg-muted focus-visible:bg-muted focus-visible:outline-none"
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -63,6 +63,10 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { usePaneSize } from "@/hooks/use-pane-size";
|
||||
import { usePaneId } from "@/hooks/use-pane-context";
|
||||
import { useKeyboardShortcuts, type GoToMailboxTarget } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useDesktopCommands } from "@/lib/desktop/commands";
|
||||
import { useDesktopPrefs } from "@/lib/desktop/preferences";
|
||||
import { hasDesktopWindows } from "@/lib/popout/support";
|
||||
import { composerDataFromDraft } from "@/lib/draft-composer-data";
|
||||
import { useUndoShortcut } from "@/hooks/use-undo-shortcut";
|
||||
import { confirmMailRemoval } from "@/lib/mail-removal-confirm";
|
||||
import { findNextUnreadIndex } from "@/lib/next-unread-email";
|
||||
@@ -1216,6 +1220,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Mountain View keeps the ticked rows while a message is read (#1231). The
|
||||
// shortcuts then act on the message on screen, as its toolbar does, and
|
||||
// reach the selection again back in the list.
|
||||
const openEmailInWindow = useOpenEmailInWindow();
|
||||
const shortcutsTargetSelection = selectedEmailKeys.size > 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) => {
|
||||
|
||||
@@ -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()!;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)} />;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 && <ProtocolHandlerSettings supportsCalendar={supportsCalendar} />}
|
||||
<DesktopKeystoreSettings />
|
||||
|
||||
<DesktopWindowSettings />
|
||||
<SettingsSection id="tour" title={tTour('settings_section')}>
|
||||
<SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}>
|
||||
<Button
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronRight } from "@/components/icons";
|
||||
import { nativeMenusAvailable, readSettledMenu, showNativeMenu } from "@/lib/desktop/native-menu";
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
@@ -199,6 +200,9 @@ interface MenuLevel {
|
||||
const MenuLevelContext = createContext<MenuLevel | null>(null);
|
||||
const neverTouched = () => false;
|
||||
|
||||
/** The desktop app shows this menu natively: it renders hidden, submenus and all, to be read. */
|
||||
const NativeMenuContext = createContext(false);
|
||||
|
||||
function useMenuLevel(recentTouch: () => boolean): MenuLevel {
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -245,11 +249,45 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||
const recentTouch = useCallback(() => Date.now() - lastTouchAt.current < 800, []);
|
||||
const level = useMenuLevel(recentTouch);
|
||||
const { request: requestLevel } = level;
|
||||
const [nativeRefused, setNativeRefused] = useState(false);
|
||||
const nativeMode = isOpen && mounted && !nativeRefused && nativeMenusAvailable();
|
||||
const positionRef = useRef(position);
|
||||
positionRef.current = position;
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) setNativeRefused(false);
|
||||
}, [isOpen]);
|
||||
|
||||
// In the desktop app: read what rendered and show that natively. See lib/desktop/native-menu.ts.
|
||||
useEffect(() => {
|
||||
if (!nativeMode) return;
|
||||
const root = localRef.current;
|
||||
if (!root) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const read = await readSettledMenu(root, () => cancelled);
|
||||
if (cancelled) return;
|
||||
if (read.unsupported) {
|
||||
setNativeRefused(true);
|
||||
return;
|
||||
}
|
||||
const { anchor: at } = positionRef.current;
|
||||
// From a right-click it opens at the pointer, which the OS knows best.
|
||||
const place = at
|
||||
? { x: at.align === "end" ? Math.max(0, at.right - root.getBoundingClientRect().width) : at.left, y: at.bottom + ANCHOR_GAP }
|
||||
: undefined;
|
||||
await showNativeMenu(read, place);
|
||||
if (!cancelled) onCloseRef.current();
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [nativeMode]);
|
||||
|
||||
const anchor = position.anchor;
|
||||
const anchorKey = anchor ? `${anchor.left},${anchor.top},${anchor.right},${anchor.bottom},${anchor.align ?? ""}` : "";
|
||||
|
||||
@@ -443,11 +481,11 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||
// Once placed (it is invisible, and can't take the focus, before), the
|
||||
// first item gets the focus.
|
||||
useEffect(() => {
|
||||
if (!isOpen || !isPositioned) return;
|
||||
if (!isOpen || !isPositioned || nativeMode) return;
|
||||
const root = localRef.current;
|
||||
if (!root || root.contains(document.activeElement)) return;
|
||||
return focusFirstItem(root);
|
||||
}, [isOpen, isPositioned]);
|
||||
}, [isOpen, isPositioned, nativeMode]);
|
||||
|
||||
const setRefs = (node: HTMLDivElement | null) => {
|
||||
localRef.current = node;
|
||||
@@ -463,6 +501,7 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||
const renderPosition = adjustedPosition ?? position;
|
||||
|
||||
return createPortal(
|
||||
<NativeMenuContext.Provider value={nativeMode}>
|
||||
<MenuLevelContext.Provider value={level}>
|
||||
<div
|
||||
ref={setRefs}
|
||||
@@ -478,7 +517,7 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||
left: renderPosition.x,
|
||||
top: renderPosition.y,
|
||||
maxWidth: `calc(100vw - ${2 * VIEWPORT_MARGIN}px)`,
|
||||
visibility: isPositioned ? "visible" : "hidden",
|
||||
visibility: isPositioned && !nativeMode ? "visible" : "hidden",
|
||||
}}
|
||||
role="menu"
|
||||
aria-label={label}
|
||||
@@ -493,7 +532,8 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</MenuLevelContext.Provider>,
|
||||
</MenuLevelContext.Provider>
|
||||
</NativeMenuContext.Provider>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -578,11 +618,11 @@ export function ContextMenuItem({
|
||||
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
|
||||
{hint ? (
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block">{label}</span>
|
||||
<span className="block" data-menu-label="">{label}</span>
|
||||
<span className="block text-xs text-muted-foreground">{hint}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex-1">{label}</span>
|
||||
<span className="flex-1" data-menu-label="">{label}</span>
|
||||
)}
|
||||
{/* Key hints mean nothing without a keyboard: hidden on touch (R8). */}
|
||||
{shortcut && (
|
||||
@@ -625,6 +665,7 @@ export function ContextMenuSubMenu({
|
||||
hint,
|
||||
}: ContextMenuSubMenuProps) {
|
||||
const parent = useContext(MenuLevelContext);
|
||||
const native = useContext(NativeMenuContext);
|
||||
const id = useId();
|
||||
// Outside a ContextMenu (a host's own panel) it keeps its own state.
|
||||
const [localOpen, setLocalOpen] = useState(false);
|
||||
@@ -704,16 +745,21 @@ export function ContextMenuSubMenu({
|
||||
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
|
||||
{hint ? (
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block">{label}</span>
|
||||
<span className="block" data-menu-label="">{label}</span>
|
||||
<span className="block text-xs text-muted-foreground">{hint}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex-1">{label}</span>
|
||||
<span className="flex-1" data-menu-label="">{label}</span>
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{isOpen && !disabled && (
|
||||
{native && !disabled && (
|
||||
<div data-submenu-panel="" role="menu" aria-label={label} hidden={false}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{!native && isOpen && !disabled && (
|
||||
<MenuLevelContext.Provider value={ownLevel}>
|
||||
<SubMenuPanel
|
||||
ref={panelRef}
|
||||
@@ -822,7 +868,7 @@ interface ContextMenuHeaderProps {
|
||||
|
||||
export function ContextMenuHeader({ children }: ContextMenuHeaderProps) {
|
||||
return (
|
||||
<div className="px-3 py-2 text-xs font-medium text-muted-foreground border-b border-border">
|
||||
<div data-context-menu-header="" className="px-3 py-2 text-xs font-medium text-muted-foreground border-b border-border">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import type { ContextMenuPosition } from "@/components/ui/context-menu";
|
||||
import { nativeMenuIsOpen } from "@/lib/desktop/native-menu";
|
||||
|
||||
interface ContextMenuState<T> {
|
||||
isOpen: boolean;
|
||||
@@ -134,12 +135,14 @@ export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
||||
};
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (nativeMenuIsOpen()) return;
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
closeContextMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = (e: Event) => {
|
||||
if (nativeMenuIsOpen()) return;
|
||||
if (menuRef.current && e.target instanceof Node && menuRef.current.contains(e.target)) {
|
||||
return;
|
||||
}
|
||||
@@ -150,6 +153,8 @@ export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
||||
// A focused control in the menu may have opened a system dialog that
|
||||
// took the focus from the window, such as a color input's (#1366).
|
||||
if (menuRef.current?.contains(document.activeElement)) return;
|
||||
// In the desktop app the menu is native: it closes itself, and says what was picked.
|
||||
if (nativeMenuIsOpen()) return;
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { act, cleanup, render } from '@testing-library/react';
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { WindowTitleBar } from '@/components/layout/window-title-bar';
|
||||
import { TITLEBAR_INSET_VAR, readTitlebarArea } from '@/lib/window-controls-overlay';
|
||||
|
||||
@@ -30,6 +30,97 @@ afterEach(() => {
|
||||
document.documentElement.style.removeProperty(TITLEBAR_INSET_VAR);
|
||||
});
|
||||
|
||||
describe('the menu strip in the desktop app', () => {
|
||||
type BarEvent = { visible: boolean; open?: string };
|
||||
|
||||
function withBar() {
|
||||
let send: (event: BarEvent) => void = () => {};
|
||||
const open = vi.fn();
|
||||
window.bulwarkDesktop = {
|
||||
version: 1,
|
||||
platform: 'win32',
|
||||
menu: {
|
||||
setState: () => {},
|
||||
bar: {
|
||||
entries: async () => [{ id: 'file', label: 'File', mnemonic: 'f' }, { id: 'edit', label: 'Edit', mnemonic: 'e' }],
|
||||
open,
|
||||
onEvent: (listener: (event: BarEvent) => void) => {
|
||||
send = listener;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
return { open, send: (event: BarEvent) => send(event) };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.bulwarkDesktop = undefined;
|
||||
});
|
||||
|
||||
const overlayOn = () => {
|
||||
Object.defineProperty(navigator, 'windowControlsOverlay', {
|
||||
configurable: true,
|
||||
value: Object.assign(new EventTarget(), { visible: true, getTitlebarAreaRect: () => ({ x: 0, y: 0, width: 800, height: 36 }) }),
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
delete (navigator as unknown as { windowControlsOverlay?: unknown }).windowControlsOverlay;
|
||||
});
|
||||
|
||||
it('Alt shows the names with the focus on the first, and Escape gives it back', async () => {
|
||||
overlayOn();
|
||||
const bar = withBar();
|
||||
const before = document.createElement('button');
|
||||
document.body.appendChild(before);
|
||||
before.focus();
|
||||
render(<WindowTitleBar />);
|
||||
await act(async () => bar.send({ visible: true }));
|
||||
const file = await screen.findByRole('menuitem', { name: 'File' });
|
||||
await waitFor(() => expect(document.activeElement).toBe(file));
|
||||
fireEvent.keyDown(file, { key: 'ArrowRight' });
|
||||
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Edit' }));
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' });
|
||||
expect(screen.queryByRole('menubar')).toBeNull();
|
||||
expect(document.activeElement).toBe(before);
|
||||
before.remove();
|
||||
});
|
||||
|
||||
it('a name opens that menu natively', async () => {
|
||||
overlayOn();
|
||||
const bar = withBar();
|
||||
render(<WindowTitleBar />);
|
||||
await act(async () => bar.send({ visible: true }));
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Edit' }));
|
||||
expect(bar.open).toHaveBeenCalledWith('edit', expect.objectContaining({ x: expect.any(Number), y: expect.any(Number) }));
|
||||
});
|
||||
|
||||
it('Alt and a letter opens that one right away', async () => {
|
||||
overlayOn();
|
||||
const bar = withBar();
|
||||
render(<WindowTitleBar />);
|
||||
await act(async () => bar.send({ visible: true, open: 'file' }));
|
||||
await waitFor(() => expect(bar.open).toHaveBeenCalledWith('file', expect.anything()));
|
||||
});
|
||||
|
||||
it('the shell can take it away again', async () => {
|
||||
overlayOn();
|
||||
const bar = withBar();
|
||||
render(<WindowTitleBar />);
|
||||
await act(async () => bar.send({ visible: true }));
|
||||
await screen.findByRole('menubar');
|
||||
await act(async () => bar.send({ visible: false }));
|
||||
expect(screen.queryByRole('menubar')).toBeNull();
|
||||
});
|
||||
|
||||
it('with no bridge there is none, and the title stays', async () => {
|
||||
overlayOn();
|
||||
render(<WindowTitleBar />);
|
||||
expect(screen.queryByRole('menubar')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the title bar', () => {
|
||||
it('shows nothing and touches nothing without the API', () => {
|
||||
installOverlay(undefined);
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ContextMenu, ContextMenuHeader, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from '@/components/ui/context-menu';
|
||||
import type { BulwarkDesktopBridge, DesktopNativeMenuItem } from '../types';
|
||||
import { accentColours, applySystemAccent } from '../system-accent';
|
||||
import { mergedDesktopState, registerDesktopSurface, resetDesktopCommands, runDesktopCommand, setDesktopBaseState } from '../commands';
|
||||
import { readMenu } from '../native-menu';
|
||||
import { noteOpenDraft, resetOpenDrafts, restoreUrlFor } from '../window-restore';
|
||||
import { readDesktopPrefs, resetDesktopPrefsCache, setDesktopPref } from '../preferences';
|
||||
import { closePopoutWindow, isPopoutPath } from '@/lib/popout/window-role';
|
||||
|
||||
function setBridge(bridge: Partial<BulwarkDesktopBridge> | undefined) {
|
||||
window.bulwarkDesktop = bridge as BulwarkDesktopBridge | undefined;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setBridge(undefined);
|
||||
resetDesktopCommands();
|
||||
resetOpenDrafts();
|
||||
localStorage.clear();
|
||||
resetDesktopPrefsCache();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function testMenu(extra?: React.ReactNode, handlers: { reply?: () => void; inbox?: () => void } = {}) {
|
||||
return (
|
||||
<>
|
||||
<ContextMenuHeader>Three messages</ContextMenuHeader>
|
||||
<ContextMenuItem label="Reply" onClick={handlers.reply ?? (() => {})} shortcut="R" hint="to the sender" />
|
||||
<ContextMenuItem label="Forward" onClick={() => {}} disabled />
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSubMenu label="Move to">
|
||||
<ContextMenuItem label="Inbox" onClick={handlers.inbox ?? (() => {})} />
|
||||
<ContextMenuItem label="R&D" onClick={() => {}} />
|
||||
</ContextMenuSubMenu>
|
||||
<ContextMenuSubMenu label="Rules" disabled>
|
||||
<ContextMenuItem label="Never shown" onClick={() => {}} />
|
||||
</ContextMenuSubMenu>
|
||||
{extra}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('an HTML menu shown natively', () => {
|
||||
let popup: ReturnType<typeof vi.fn>;
|
||||
let answer: (id: string | null) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
popup = vi.fn(() => new Promise<string | null>((resolve) => { answer = resolve; }));
|
||||
setBridge({ version: 1, platform: 'win32', menu: { setState: () => {}, popup: popup as never } });
|
||||
});
|
||||
|
||||
async function openMenu(children: React.ReactNode, onClose = vi.fn()) {
|
||||
render(<ContextMenu isOpen position={{ x: 10, y: 10 }} onClose={onClose}>{children}</ContextMenu>);
|
||||
await waitFor(() => expect(popup).toHaveBeenCalled());
|
||||
return { items: popup.mock.calls[0][0] as DesktopNativeMenuItem[], at: popup.mock.calls[0][1], onClose };
|
||||
}
|
||||
|
||||
it('reads every kind of row, in order, with submenus', async () => {
|
||||
const { items } = await openMenu(testMenu());
|
||||
expect(items.map((item) => item.label ?? item.type)).toEqual(['Three messages', 'Reply', 'Forward', 'separator', 'Move to', 'Rules']);
|
||||
expect(items[0].enabled).toBe(false);
|
||||
expect(items[2].enabled).toBe(false);
|
||||
expect(items[4].submenu?.map((item) => item.label)).toEqual(['Inbox', 'R&D']);
|
||||
// A disabled submenu shows, and has nothing in it.
|
||||
expect(items[5].enabled).toBe(false);
|
||||
expect(items[5].submenu).toBeUndefined();
|
||||
});
|
||||
|
||||
it('takes the label without the shortcut or the hint', async () => {
|
||||
const { items } = await openMenu(testMenu());
|
||||
expect(items[1].label).toBe('Reply');
|
||||
});
|
||||
|
||||
it('at the pointer from a right-click, under the button from a button', async () => {
|
||||
const { at } = await openMenu(testMenu());
|
||||
expect(at).toBeUndefined();
|
||||
popup.mockClear();
|
||||
render(
|
||||
<ContextMenu isOpen position={{ x: 0, y: 0, anchor: { left: 40, top: 50, right: 90, bottom: 70 } }} onClose={() => {}}>
|
||||
<ContextMenuItem label="One" onClick={() => {}} />
|
||||
</ContextMenu>,
|
||||
);
|
||||
await waitFor(() => expect(popup).toHaveBeenCalled());
|
||||
expect(popup.mock.calls[0][1]).toEqual({ x: 40, y: 74 });
|
||||
});
|
||||
|
||||
it('a pick runs the HTML row, the one a browser would run, and closes the menu', async () => {
|
||||
const reply = vi.fn();
|
||||
const inbox = vi.fn();
|
||||
const { items, onClose } = await openMenu(testMenu(undefined, { reply, inbox }));
|
||||
await act(async () => {
|
||||
answer(items[4].submenu![0].id!);
|
||||
});
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(inbox).toHaveBeenCalledTimes(1);
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dismissing it runs nothing and closes the menu', async () => {
|
||||
const reply = vi.fn();
|
||||
const { onClose } = await openMenu(testMenu(undefined, { reply }));
|
||||
await act(async () => {
|
||||
answer(null);
|
||||
});
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the hidden copy is never visible', async () => {
|
||||
await openMenu(testMenu());
|
||||
const menu = document.body.querySelector<HTMLElement>('[role="menu"]');
|
||||
expect(menu?.style.visibility).toBe('hidden');
|
||||
});
|
||||
|
||||
it('stays HTML when something in it can\'t be shown natively', async () => {
|
||||
render(
|
||||
<ContextMenu isOpen position={{ x: 10, y: 10 }} onClose={() => {}}>
|
||||
{testMenu(<input type="color" aria-label="Colour" />)}
|
||||
</ContextMenu>,
|
||||
);
|
||||
await waitFor(() => expect(document.body.querySelector<HTMLElement>('[role="menu"]')?.style.visibility).toBe('visible'));
|
||||
expect(popup).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('menuitem', { name: /Reply/ })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('a row with no words stays HTML too: a colour swatch says nothing in a native menu', () => {
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = '<button role="menuitem"><svg></svg></button>';
|
||||
expect(readMenu(root).unsupported).toBe(true);
|
||||
});
|
||||
|
||||
it('a search field over a list is left out, and what it searches stays', () => {
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = '<input type="search" /><button role="menuitem">Inbox</button>';
|
||||
const read = readMenu(root);
|
||||
expect(read.unsupported).toBe(false);
|
||||
expect(read.items.map((item) => item.label)).toEqual(['Inbox']);
|
||||
});
|
||||
|
||||
it('reads check and radio rows', () => {
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = '<button role="menuitemcheckbox" aria-checked="true">Show</button><button role="menuitemradio" aria-checked="false">Other</button>';
|
||||
expect(readMenu(root).items).toMatchObject([
|
||||
{ type: 'checkbox', checked: true, label: 'Show' },
|
||||
{ type: 'radio', checked: false, label: 'Other' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the same menu in a browser', () => {
|
||||
it('is the HTML menu, on screen, and no bridge is asked', async () => {
|
||||
render(<ContextMenu isOpen position={{ x: 10, y: 10 }} onClose={() => {}}>{testMenu()}</ContextMenu>);
|
||||
await waitFor(() => expect(document.body.querySelector<HTMLElement>('[role="menu"]')?.style.visibility).toBe('visible'));
|
||||
// A closed submenu's rows aren't in the page.
|
||||
expect(screen.queryByRole('menuitem', { name: 'Inbox' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('menu commands', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('the newest surface answers, and an older one is back when it goes', () => {
|
||||
const older = vi.fn();
|
||||
const newer = vi.fn();
|
||||
registerDesktopSurface({ handlers: { 'message.reply': older }, state: {} });
|
||||
const stop = registerDesktopSurface({ handlers: { 'message.reply': newer }, state: {} });
|
||||
expect(runDesktopCommand('message.reply')).toBe(true);
|
||||
expect(newer).toHaveBeenCalledTimes(1);
|
||||
expect(older).not.toHaveBeenCalled();
|
||||
stop();
|
||||
runDesktopCommand('message.reply');
|
||||
expect(older).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('says when nothing answered, so the host can take it', () => {
|
||||
expect(runDesktopCommand('go.calendar')).toBe(false);
|
||||
});
|
||||
|
||||
it('the shell gets the state, surfaces over the base', () => {
|
||||
const setState = vi.fn();
|
||||
setBridge({ version: 1, platform: 'win32', menu: { setState } });
|
||||
setDesktopBaseState({ 'message.reply': false, 'file.print': true });
|
||||
registerDesktopSurface({ handlers: {}, state: { 'message.reply': true } });
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(setState).toHaveBeenLastCalledWith({ 'message.reply': true, 'file.print': true });
|
||||
expect(mergedDesktopState()['file.print']).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composers that come back', () => {
|
||||
it('tell the shell which draft they hold, and forget it when they go', () => {
|
||||
const setRestore = vi.fn();
|
||||
setBridge({ version: 1, platform: 'win32', windows: { setRestore } });
|
||||
noteOpenDraft('a', { draftId: 'd1', localAccountId: 'login', ownerAccountId: 'group' });
|
||||
expect(setRestore).toHaveBeenLastCalledWith([expect.stringContaining('draft=d1')]);
|
||||
expect(setRestore.mock.lastCall![0][0]).toContain('account=login');
|
||||
expect(setRestore.mock.lastCall![0][0]).toContain('owner=group');
|
||||
noteOpenDraft('a', { draftId: 'd2' });
|
||||
expect(setRestore.mock.lastCall![0]).toHaveLength(1);
|
||||
expect(setRestore.mock.lastCall![0][0]).toContain('draft=d2');
|
||||
noteOpenDraft('a', null);
|
||||
expect(setRestore).toHaveBeenLastCalledWith([]);
|
||||
});
|
||||
|
||||
it('are pop-out addresses the window type check recognises', () => {
|
||||
expect(restoreUrlFor({ draftId: 'x' })).toMatch(/\/popout\/compose\?draft=x$/);
|
||||
});
|
||||
|
||||
it('do nothing in a browser', () => {
|
||||
expect(() => noteOpenDraft('a', { draftId: 'd1' })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a pop-out closing itself', () => {
|
||||
it('goes through the shell in the desktop app, not window.close', () => {
|
||||
const closeSelf = vi.fn();
|
||||
const close = vi.spyOn(window, 'close').mockImplementation(() => {});
|
||||
setBridge({ version: 1, platform: 'win32', windows: { closeSelf } });
|
||||
closePopoutWindow();
|
||||
expect(closeSelf).toHaveBeenCalledTimes(1);
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
close.mockRestore();
|
||||
});
|
||||
|
||||
it('uses window.close in a browser', () => {
|
||||
vi.useFakeTimers();
|
||||
const close = vi.spyOn(window, 'close').mockImplementation(() => {});
|
||||
closePopoutWindow();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
close.mockRestore();
|
||||
});
|
||||
|
||||
it('counts the warm spare as a pop-out, which is a guest of the main window', () => {
|
||||
expect(isPopoutPath('/popout/spare')).toBe(true);
|
||||
expect(isPopoutPath('/de/popout/spare')).toBe(true);
|
||||
expect(isPopoutPath('/popout/spare/x')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('window settings', () => {
|
||||
it('both default to on, and stay on this computer', () => {
|
||||
expect(readDesktopPrefs()).toEqual({ openOnDoubleClick: true, restoreWindows: true });
|
||||
setDesktopPref('restoreWindows', false);
|
||||
resetDesktopPrefsCache();
|
||||
expect(readDesktopPrefs().restoreWindows).toBe(false);
|
||||
expect(readDesktopPrefs().openOnDoubleClick).toBe(true);
|
||||
});
|
||||
|
||||
it('a damaged value is the default', () => {
|
||||
localStorage.setItem('bulwark-desktop-window-prefs', '{nope');
|
||||
expect(readDesktopPrefs().restoreWindows).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the OS accent colour', () => {
|
||||
beforeEach(() => setBridge({ version: 1, platform: 'win32' }));
|
||||
|
||||
const apply = (input: Partial<Parameters<typeof applySystemAccent>[0]>) => {
|
||||
const root = document.createElement('div');
|
||||
applySystemAccent({ accent: '#0078d4', dark: false, followsSystem: true, ...input }, root);
|
||||
return root.style.getPropertyValue('--color-primary');
|
||||
};
|
||||
|
||||
it('is used while the theme follows the system', () => {
|
||||
expect(apply({})).toBe('#0078d4');
|
||||
});
|
||||
|
||||
it('not under a theme the user picked', () => {
|
||||
expect(apply({ followsSystem: false })).toBe('');
|
||||
});
|
||||
|
||||
it('not in a contrast theme', () => {
|
||||
expect(apply({ highContrast: true })).toBe('');
|
||||
});
|
||||
|
||||
it('not where it would vanish against the page', () => {
|
||||
expect(accentColours('#fefefe', false)).toBeNull();
|
||||
expect(accentColours('#050505', true)).toBeNull();
|
||||
});
|
||||
|
||||
it('text on it is what reads', () => {
|
||||
expect(accentColours('#0078d4', false)?.foreground).toBe('#ffffff');
|
||||
expect(accentColours('#ffd400', true)?.foreground).toBe('#000000');
|
||||
});
|
||||
|
||||
it('a removed accent takes its colours off again', () => {
|
||||
const root = document.createElement('div');
|
||||
applySystemAccent({ accent: '#0078d4', dark: false, followsSystem: true }, root);
|
||||
applySystemAccent({ dark: false, followsSystem: true }, root);
|
||||
expect(root.style.getPropertyValue('--color-primary')).toBe('');
|
||||
expect(root.style.getPropertyValue('--color-primary-foreground')).toBe('');
|
||||
});
|
||||
|
||||
it('does nothing in a browser', () => {
|
||||
setBridge(undefined);
|
||||
expect(apply({})).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* The desktop app's menu bar, on the webmail's side. The shell shows the menu and
|
||||
* sends an id; what runs is the same handler the shortcut or button runs, from
|
||||
* whichever part of the app registered it. What's on screen decides what's enabled:
|
||||
* a surface reports that here, and the newest surface wins per command.
|
||||
*
|
||||
* Nothing happens in a browser: there's no bridge to publish to, and nobody sends
|
||||
* an id.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { getDesktopBridge } from './bridge';
|
||||
import type { DesktopMenuState } from './types';
|
||||
|
||||
export type DesktopCommandHandlers = Record<string, (() => void) | undefined>;
|
||||
|
||||
interface Surface {
|
||||
handlers: DesktopCommandHandlers;
|
||||
state: DesktopMenuState;
|
||||
}
|
||||
|
||||
const surfaces = new Set<Surface>();
|
||||
let base: DesktopMenuState = {};
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** What the shell should show now: the base, with each surface over it, newest last. */
|
||||
export function mergedDesktopState(): DesktopMenuState {
|
||||
const merged: DesktopMenuState = { ...base };
|
||||
for (const surface of surfaces) Object.assign(merged, surface.state);
|
||||
return merged;
|
||||
}
|
||||
|
||||
function publish(): void {
|
||||
if (timer) return;
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
getDesktopBridge()?.menu?.setState(mergedDesktopState());
|
||||
}, 30);
|
||||
}
|
||||
|
||||
/** What holds whatever is on screen: signed in or not, the device policy. */
|
||||
export function setDesktopBaseState(state: DesktopMenuState): void {
|
||||
base = state;
|
||||
publish();
|
||||
}
|
||||
|
||||
/** Runs a command from the newest surface that has it. False when none does. */
|
||||
export function runDesktopCommand(id: string): boolean {
|
||||
for (const surface of [...surfaces].reverse()) {
|
||||
const handler = surface.handlers[id];
|
||||
if (handler) {
|
||||
handler();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function registerDesktopSurface(surface: Surface): () => void {
|
||||
surfaces.add(surface);
|
||||
publish();
|
||||
return () => {
|
||||
surfaces.delete(surface);
|
||||
publish();
|
||||
};
|
||||
}
|
||||
|
||||
function sameState(a: DesktopMenuState, b: DesktopMenuState): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* This part of the app answers these commands while `enabled`. Handlers can be new
|
||||
* on every render: the newest ones run, and only a changed state goes to the shell.
|
||||
*/
|
||||
export function useDesktopCommands(handlers: DesktopCommandHandlers, state: DesktopMenuState, enabled = true): void {
|
||||
const surfaceRef = useRef<Surface>({ handlers, state });
|
||||
surfaceRef.current.handlers = handlers;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !getDesktopBridge()?.menu) return;
|
||||
return registerDesktopSurface(surfaceRef.current);
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sameState(surfaceRef.current.state, state)) return;
|
||||
surfaceRef.current.state = state;
|
||||
publish();
|
||||
});
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetDesktopCommands(): void {
|
||||
surfaces.clear();
|
||||
base = {};
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Turns an HTML menu into a native one. The ContextMenu component renders as it does
|
||||
* in a browser, hidden, with its submenus open. This reads the result and shows that
|
||||
* natively. A pick clicks the row the HTML menu has for it, so a menu has one list of
|
||||
* entries with one set of handlers and the two can't drift.
|
||||
*
|
||||
* A menu with something a native one can't show (a plugin's frame, a colour picker,
|
||||
* a row with no text) stays HTML. So does anything marked data-native-menu="html".
|
||||
*/
|
||||
|
||||
import { getDesktopBridge } from './bridge';
|
||||
import type { DesktopNativeMenuItem } from './types';
|
||||
|
||||
export function nativeMenusAvailable(): boolean {
|
||||
return !!getDesktopBridge()?.menu?.popup;
|
||||
}
|
||||
|
||||
export interface ReadMenu {
|
||||
items: DesktopNativeMenuItem[];
|
||||
/** The HTML row behind each native item's id. */
|
||||
elements: Map<string, HTMLElement>;
|
||||
/** There's something here a native menu can't show. */
|
||||
unsupported: boolean;
|
||||
}
|
||||
|
||||
const ROW = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], button, a[href]';
|
||||
|
||||
function isDisabled(el: HTMLElement): boolean {
|
||||
return (el as HTMLButtonElement).disabled === true || el.getAttribute('aria-disabled') === 'true';
|
||||
}
|
||||
|
||||
function labelOf(el: HTMLElement): string {
|
||||
const named = el.getAttribute('aria-label') ?? el.querySelector<HTMLElement>('[data-menu-label]')?.textContent ?? el.textContent ?? '';
|
||||
return named.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function isHidden(el: Element): boolean {
|
||||
return typeof window.getComputedStyle === 'function' && window.getComputedStyle(el).display === 'none';
|
||||
}
|
||||
|
||||
export function readMenu(root: HTMLElement): ReadMenu {
|
||||
const elements = new Map<string, HTMLElement>();
|
||||
let unsupported = root.querySelector('[data-native-menu="html"], iframe, select, textarea, input[type="color"]') !== null;
|
||||
let next = 1;
|
||||
|
||||
const walk = (container: Element, into: DesktopNativeMenuItem[]): void => {
|
||||
for (const child of Array.from(container.children)) {
|
||||
if (!(child instanceof HTMLElement) || isHidden(child)) continue;
|
||||
if (child.getAttribute('role') === 'separator') {
|
||||
into.push({ type: 'separator' });
|
||||
continue;
|
||||
}
|
||||
if (child.dataset.submenuPanel !== undefined) continue;
|
||||
if (child.dataset.contextMenuHeader !== undefined) {
|
||||
const label = labelOf(child);
|
||||
if (label) into.push({ id: `h${next++}`, label, enabled: false });
|
||||
continue;
|
||||
}
|
||||
if (child.dataset.submenuTrigger !== undefined) {
|
||||
const label = labelOf(child);
|
||||
if (isDisabled(child)) {
|
||||
into.push({ id: `s${next++}`, label, enabled: false });
|
||||
continue;
|
||||
}
|
||||
const panel = child.parentElement?.querySelector<HTMLElement>(':scope > [data-submenu-panel]');
|
||||
if (!panel) {
|
||||
unsupported = true;
|
||||
continue;
|
||||
}
|
||||
const submenu: DesktopNativeMenuItem[] = [];
|
||||
walk(panel, submenu);
|
||||
into.push({ label, submenu });
|
||||
continue;
|
||||
}
|
||||
if (child.matches(ROW)) {
|
||||
// A row that opens its own popup: there's nothing to click through to.
|
||||
if (child.hasAttribute('aria-haspopup')) {
|
||||
unsupported = true;
|
||||
continue;
|
||||
}
|
||||
const label = labelOf(child);
|
||||
if (!label) {
|
||||
// A swatch or an icon says nothing in a native menu.
|
||||
unsupported = true;
|
||||
continue;
|
||||
}
|
||||
const id = `i${next++}`;
|
||||
elements.set(id, child);
|
||||
const role = child.getAttribute('role');
|
||||
into.push({
|
||||
id,
|
||||
label,
|
||||
enabled: !isDisabled(child),
|
||||
...(role === 'menuitemcheckbox' || role === 'menuitemradio'
|
||||
? { type: role === 'menuitemcheckbox' ? 'checkbox' as const : 'radio' as const, checked: child.getAttribute('aria-checked') === 'true' }
|
||||
: {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// A search field over a list is no use where you can't type.
|
||||
if (child instanceof HTMLInputElement) continue;
|
||||
walk(child, into);
|
||||
}
|
||||
};
|
||||
|
||||
const items: DesktopNativeMenuItem[] = [];
|
||||
walk(root, items);
|
||||
return { items, elements, unsupported };
|
||||
}
|
||||
|
||||
const SETTLE_MS = 30;
|
||||
const SETTLE_LIMIT_MS = 400;
|
||||
|
||||
/**
|
||||
* The menu once it stopped changing. Entries can render a moment after the menu
|
||||
* (the mail menu waits for its data), and a native menu can't grow once it's up.
|
||||
*/
|
||||
export async function readSettledMenu(root: HTMLElement, cancelled: () => boolean): Promise<ReadMenu> {
|
||||
const started = Date.now();
|
||||
let previous = readMenu(root);
|
||||
while (!cancelled() && Date.now() - started < SETTLE_LIMIT_MS) {
|
||||
await new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
|
||||
const current = readMenu(root);
|
||||
if (current.items.length > 0 && JSON.stringify(current.items) === JSON.stringify(previous.items)) return current;
|
||||
previous = current;
|
||||
}
|
||||
return previous;
|
||||
}
|
||||
|
||||
/** Shows it and clicks the row picked. Resolves when the menu has gone. */
|
||||
export async function showNativeMenu(read: ReadMenu, at?: { x: number; y: number }): Promise<void> {
|
||||
const popup = getDesktopBridge()?.menu?.popup;
|
||||
if (!popup) return;
|
||||
document.documentElement.dataset.nativeMenuOpen = '';
|
||||
try {
|
||||
const picked = await popup(read.items, at);
|
||||
if (picked) read.elements.get(picked)?.click();
|
||||
} finally {
|
||||
delete document.documentElement.dataset.nativeMenuOpen;
|
||||
}
|
||||
}
|
||||
|
||||
/** A native menu is up: the window's blur or a stray event says nothing about it. */
|
||||
export function nativeMenuIsOpen(): boolean {
|
||||
return typeof document !== 'undefined' && document.documentElement.dataset.nativeMenuOpen !== undefined;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* The OS accent colour, when the user's theme is "system". Buttons and selection take
|
||||
* it the way native controls do. Not under a theme the user chose, and not when the
|
||||
* colour would be hard to see: the app's own stays then.
|
||||
*/
|
||||
|
||||
import { getDesktopBridge } from './bridge';
|
||||
|
||||
const VARIABLES = ['--color-primary', '--color-ring', '--color-primary-foreground'] as const;
|
||||
|
||||
function channels(hex: string): [number, number, number] | null {
|
||||
const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
|
||||
if (!match) return null;
|
||||
return [parseInt(match[1], 16), parseInt(match[2], 16), parseInt(match[3], 16)];
|
||||
}
|
||||
|
||||
function luminance([r, g, b]: [number, number, number]): number {
|
||||
const [lr, lg, lb] = [r, g, b].map((value) => {
|
||||
const c = value / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb;
|
||||
}
|
||||
|
||||
function contrast(a: number, b: number): number {
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
}
|
||||
|
||||
/** Non-text contrast, WCAG 1.4.11. */
|
||||
const MIN_AGAINST_BACKGROUND = 3;
|
||||
const MIN_TEXT = 4.5;
|
||||
|
||||
/**
|
||||
* The colours to use, or null for the app's own: the accent has to stand out from the
|
||||
* page, and text on it has to be readable in either black or white.
|
||||
*/
|
||||
export function accentColours(accent: string, dark: boolean): { primary: string; foreground: string } | null {
|
||||
const rgb = channels(accent);
|
||||
if (!rgb) return null;
|
||||
const own = luminance(rgb);
|
||||
// The standard theme's page backgrounds.
|
||||
const page = dark ? 0.0091 : 1;
|
||||
if (contrast(own, page) < MIN_AGAINST_BACKGROUND) return null;
|
||||
const onWhite = contrast(own, 1);
|
||||
const onBlack = contrast(own, 0);
|
||||
if (Math.max(onWhite, onBlack) < MIN_TEXT) return null;
|
||||
return { primary: accent, foreground: onWhite >= MIN_TEXT ? '#ffffff' : '#000000' };
|
||||
}
|
||||
|
||||
export interface AccentInput {
|
||||
accent?: string;
|
||||
/** Whether the page is dark now. */
|
||||
dark: boolean;
|
||||
/** The user's theme setting is "system" and no installed theme is active. */
|
||||
followsSystem: boolean;
|
||||
/** A contrast theme has its own colours and they win. */
|
||||
highContrast?: boolean;
|
||||
}
|
||||
|
||||
export function applySystemAccent({ accent, dark, followsSystem, highContrast }: AccentInput, root: HTMLElement = document.documentElement): void {
|
||||
const colours = accent && followsSystem && !highContrast && getDesktopBridge() ? accentColours(accent, dark) : null;
|
||||
if (!colours) {
|
||||
for (const name of VARIABLES) root.style.removeProperty(name);
|
||||
return;
|
||||
}
|
||||
root.style.setProperty('--color-primary', colours.primary);
|
||||
root.style.setProperty('--color-ring', colours.primary);
|
||||
root.style.setProperty('--color-primary-foreground', colours.foreground);
|
||||
}
|
||||
+96
-4
@@ -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 =
|
||||
@@ -190,6 +280,8 @@ export interface BulwarkDesktopBridge {
|
||||
theme?: DesktopThemeBridge;
|
||||
menu?: DesktopMenuBridge;
|
||||
shell?: DesktopShellBridge;
|
||||
dialogs?: DesktopDialogsBridge;
|
||||
clipboard?: DesktopClipboardBridge;
|
||||
lifecycle?: DesktopLifecycleBridge;
|
||||
servers?: DesktopServersBridge;
|
||||
auth?: DesktopAuthBridge;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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('/'));
|
||||
|
||||
@@ -6545,6 +6545,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": "أكمل تسجيل الدخول في المتصفح",
|
||||
|
||||
@@ -6545,6 +6545,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",
|
||||
|
||||
@@ -6545,6 +6545,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",
|
||||
|
||||
@@ -6545,6 +6545,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",
|
||||
|
||||
@@ -6545,6 +6545,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",
|
||||
|
||||
@@ -6545,6 +6545,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",
|
||||
|
||||
@@ -6545,6 +6545,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",
|
||||
|
||||
@@ -6545,6 +6545,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",
|
||||
|
||||
@@ -6545,6 +6545,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": "ورود را در مرورگر کامل کنید",
|
||||
|
||||
@@ -6545,6 +6545,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",
|
||||
|
||||
@@ -6546,6 +6546,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": "יש להשלים את הכניסה בדפדפן",
|
||||
|
||||
@@ -6545,6 +6545,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",
|
||||
|
||||
@@ -6545,6 +6545,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",
|
||||
|
||||
@@ -6545,6 +6545,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": "ブラウザでサインインを完了してください",
|
||||
|
||||
@@ -6545,6 +6545,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": "브라우저에서 로그인을 마치세요",
|
||||
|
||||
@@ -6545,6 +6545,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ā",
|
||||
|
||||
@@ -6545,6 +6545,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": "Нэвтрэлтээ хөтөч дээр дуусгана уу",
|
||||
|
||||
@@ -6545,6 +6545,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",
|
||||
|
||||
@@ -6545,6 +6545,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",
|
||||
|
||||
@@ -6545,6 +6545,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",
|
||||
|
||||
@@ -6545,6 +6545,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",
|
||||
|
||||
@@ -6545,6 +6545,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",
|
||||
|
||||
@@ -6545,6 +6545,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": "Завершите вход в браузере",
|
||||
|
||||
@@ -6545,6 +6545,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",
|
||||
|
||||
@@ -6553,6 +6553,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": "ลงชื่อเข้าใช้ให้เสร็จในเบราว์เซอร์",
|
||||
|
||||
@@ -6545,6 +6545,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",
|
||||
|
||||
@@ -6545,6 +6545,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": "Завершіть вхід у браузері",
|
||||
|
||||
@@ -6557,6 +6557,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",
|
||||
|
||||
@@ -6545,6 +6545,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": "請在瀏覽器中完成登入",
|
||||
|
||||
@@ -6545,6 +6545,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": "请在浏览器中完成登录",
|
||||
|
||||
Reference in New Issue
Block a user