diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 992bef2ec..762f30ab9 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -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 && } {!IS_LITE && } + {!IS_LITE && } {children} diff --git a/app/(main)/[locale]/popout/spare/page.tsx b/app/(main)/[locale]/popout/spare/page.tsx new file mode 100644 index 000000000..1eafe6639 --- /dev/null +++ b/app/(main)/[locale]/popout/spare/page.tsx @@ -0,0 +1,7 @@ +import { PopoutSpareApp } from "@/components/popout/popout-spare-app"; + +// The desktop app's warm spare (components/popout/popout-spare-app.tsx). Not in +// Lite: scripts/lite removes this tree. +export default function PopoutSparePage() { + return ; +} diff --git a/app/globals.css b/app/globals.css index 212b080c2..5d79028fd 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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); diff --git a/components/desktop/__tests__/desktop-host.test.tsx b/components/desktop/__tests__/desktop-host.test.tsx new file mode 100644 index 000000000..b44514e6f --- /dev/null +++ b/components/desktop/__tests__/desktop-host.test.tsx @@ -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) | 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) => { 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()).not.toThrow(); + }); + + it('runs a command from what\'s on the page first', () => { + const shell = fakeBridge(); + render(); + 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(); + 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(); + shell.run('link.folder:sent'); + expect(push).toHaveBeenLastCalledWith('/mail/folder/sent'); + }); + + it('an unknown command does nothing', () => { + const shell = fakeBridge(); + render(); + 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(); + 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(); + 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(); + await act(async () => {}); + expect(shell.sessionReady).not.toHaveBeenCalled(); + first.unmount(); + useAuthStore.setState({ isAuthenticated: true } as never); + window.history.replaceState(null, '', '/popout/compose'); + render(); + await act(async () => {}); + expect(shell.sessionReady).not.toHaveBeenCalled(); + }); + + it('passes the restore setting on', async () => { + const shell = fakeBridge(); + render(); + 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(); + 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(); + act(() => useOfflineStore.setState({ outbox: [{ id: 1 }, { id: 2 }] } as never)); + await expect(shell.flush()).resolves.toEqual({ unsent: 2 }); + act(() => useOfflineStore.setState({ outbox: [] } as never)); + }); +}); diff --git a/components/desktop/desktop-host.tsx b/components/desktop/desktop-host.tsx new file mode 100644 index 000000000..83c6722e3 --- /dev/null +++ b/components/desktop/desktop-host.tsx @@ -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 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; +} diff --git a/components/email/__tests__/composer-close-guard.test.tsx b/components/email/__tests__/composer-close-guard.test.tsx index 8ac5e5b82..9ef9370a7 100644 --- a/components/email/__tests__/composer-close-guard.test.tsx +++ b/components/email/__tests__/composer-close-guard.test.tsx @@ -1,5 +1,5 @@ import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react'; -import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; import React from 'react'; import { EmailComposer } from '../email-composer'; import { useAuthStore } from '@/stores/auth-store'; @@ -235,6 +235,39 @@ describe('composer close guard with a follow-up', () => { expect(afterClose).not.toHaveBeenCalled(); }); + describe('in the desktop app', () => { + afterEach(() => { + window.bulwarkDesktop = undefined; + }); + + const nativeBridge = (answer: number) => { + const confirm = vi.fn(async () => answer); + window.bulwarkDesktop = { version: 1, platform: 'win32', dialogs: { confirm } } as never; + return confirm; + }; + + it('asks through the OS, and shows no dialog of its own', async () => { + const confirm = nativeBridge(2); + const { requestCloseRef, afterClose, onClose } = renderWithCloseRef(); + act(() => requestCloseRef.current!(afterClose)); + await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1)); + expect(confirm.mock.calls[0]).toMatchObject([{ message: 'close_draft_title', buttons: ['save', 'discard', 'cancel'], defaultId: 0 }]); + expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument(); + // The last button is Cancel: nothing happens. + await act(async () => {}); + expect(onClose).not.toHaveBeenCalled(); + expect(afterClose).not.toHaveBeenCalled(); + }); + + it('Discard discards, and the follow-up runs', async () => { + nativeBridge(1); + const { requestCloseRef, afterClose, onClose } = renderWithCloseRef(); + act(() => requestCloseRef.current!(afterClose)); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1)); + }); + }); + it('runs the follow-up after discarding', async () => { const { requestCloseRef, afterClose, onClose } = renderWithCloseRef(); diff --git a/components/email/__tests__/reader-attachments.test.tsx b/components/email/__tests__/reader-attachments.test.tsx index 8e0f1fae4..67c6913e7 100644 --- a/components/email/__tests__/reader-attachments.test.tsx +++ b/components/email/__tests__/reader-attachments.test.tsx @@ -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 { + afterEach(() => { + window.bulwarkDesktop = undefined; + cleanup(); + }); + + const openMenu = async (popupAnswer: (items: { id?: string; label?: string }[]) => string | null, overrides: Partial[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>)[handler]).toHaveBeenCalledTimes(1)); + expect((props as unknown as Record>)[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']); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 44a474b99..67be255df 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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(); } diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index e75a260ca..cd85eacd2 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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(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. diff --git a/components/email/reader-attachments.tsx b/components/email/reader-attachments.tsx index 0e3aa1fbd..d04e16c09 100644 --- a/components/email/reader-attachments.tsx +++ b/components/email/reader-attachments.tsx @@ -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 { layout?: ReaderAttachmentLayout; /** Object URLs of image attachments, keyed by attachment id. */ thumbUrls: Record; + /** + * 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({ onDownload, onDownloadAll, onSaveAllToFolder, + onCopyFile, downloadingAll, downloadsOff = false, onSaveToFiles, @@ -105,6 +114,11 @@ export function ReaderAttachments({ const tFiles = useTranslations("files"); const tDevice = useTranslations("device_policy"); const tWindows = useTranslations("pwaWindows"); + const tDesktop = useTranslations("desktopUi.attachment"); + const menu = useContextMenu(); + 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({ 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) => ( -
+
menu.openContextMenu(event, attachment) : undefined}> {renderItem ? renderItem(attachment, (drag) => draw(attachment, drag)) : draw(attachment, NO_DRAG)} @@ -324,6 +338,19 @@ export function ReaderAttachments({ )}
+ {desktopMenu && menuTarget && ( + + {!locked(menuTarget) && ( + { menu.closeContextMenu(); onOpen(menuTarget); }} /> + )} + {!downloadsOff && ( + { menu.closeContextMenu(); onDownload(menuTarget); }} /> + )} + {!downloadsOff && onCopyFile && ( + { menu.closeContextMenu(); onCopyFile(menuTarget); }} /> + )} + + )} ); } diff --git a/components/layout/window-title-bar.tsx b/components/layout/window-title-bar.tsx index 7f4e306fa..c2fd2ce84 100644 --- a/components/layout/window-title-bar.tsx +++ b/components/layout/window-title-bar.tsx @@ -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(null); const [title, setTitle] = useState(""); + const [entries, setEntries] = useState(null); + const stripRef = useRef(null); + const previousFocus = useRef(null); + // What to do once the names are on screen: open that menu, or null to focus the first name. + const afterShow = useRef(undefined); + + const openMenu = useCallback((id: string) => { + const button = stripRef.current?.querySelector(`[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("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) => { + const buttons = Array.from(stripRef.current?.querySelectorAll("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 ( +
+ {entries.map((entry) => ( + + ))} +
+ ); + } + return ( - {isOpen && !disabled && ( + {native && !disabled && ( + + )} + {!native && isOpen && !disabled && ( +
{children}
); diff --git a/hooks/use-context-menu.ts b/hooks/use-context-menu.ts index dfdc02348..eb412b42d 100644 --- a/hooks/use-context-menu.ts +++ b/hooks/use-context-menu.ts @@ -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 { isOpen: boolean; @@ -134,12 +135,14 @@ export function useContextMenu(): UseContextMenuReturn { }; 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(): UseContextMenuReturn { // 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(); }; diff --git a/lib/__tests__/window-title-bar.test.tsx b/lib/__tests__/window-title-bar.test.tsx index b0d4bb11b..e785ffb33 100644 --- a/lib/__tests__/window-title-bar.test.tsx +++ b/lib/__tests__/window-title-bar.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + expect(screen.queryByRole('menubar')).toBeNull(); + }); +}); + describe('the title bar', () => { it('shows nothing and touches nothing without the API', () => { installOverlay(undefined); diff --git a/lib/desktop/__tests__/native-ui.test.tsx b/lib/desktop/__tests__/native-ui.test.tsx new file mode 100644 index 000000000..2bab20eb1 --- /dev/null +++ b/lib/desktop/__tests__/native-ui.test.tsx @@ -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 | 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 ( + <> + Three messages + {})} shortcut="R" hint="to the sender" /> + {}} disabled /> + + + {})} /> + {}} /> + + + {}} /> + + {extra} + + ); +} + +describe('an HTML menu shown natively', () => { + let popup: ReturnType; + let answer: (id: string | null) => void; + + beforeEach(() => { + popup = vi.fn(() => new Promise((resolve) => { answer = resolve; })); + setBridge({ version: 1, platform: 'win32', menu: { setState: () => {}, popup: popup as never } }); + }); + + async function openMenu(children: React.ReactNode, onClose = vi.fn()) { + render({children}); + 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( + {}}> + {}} /> + , + ); + 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('[role="menu"]'); + expect(menu?.style.visibility).toBe('hidden'); + }); + + it('stays HTML when something in it can\'t be shown natively', async () => { + render( + {}}> + {testMenu()} + , + ); + await waitFor(() => expect(document.body.querySelector('[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 = ''; + 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 = ''; + 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 = ''; + 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( {}}>{testMenu()}); + await waitFor(() => expect(document.body.querySelector('[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[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(''); + }); +}); diff --git a/lib/desktop/commands.ts b/lib/desktop/commands.ts new file mode 100644 index 000000000..5013eeb66 --- /dev/null +++ b/lib/desktop/commands.ts @@ -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 void) | undefined>; + +interface Surface { + handlers: DesktopCommandHandlers; + state: DesktopMenuState; +} + +const surfaces = new Set(); +let base: DesktopMenuState = {}; +let timer: ReturnType | 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({ 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; +} diff --git a/lib/desktop/native-menu.ts b/lib/desktop/native-menu.ts new file mode 100644 index 000000000..ae8eca8a5 --- /dev/null +++ b/lib/desktop/native-menu.ts @@ -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; + /** 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('[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(); + 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(':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 { + 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 { + 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; +} diff --git a/lib/desktop/preferences.ts b/lib/desktop/preferences.ts new file mode 100644 index 000000000..f9894c5c8 --- /dev/null +++ b/lib/desktop/preferences.ts @@ -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 = {}; + try { + const raw = typeof localStorage === 'undefined' ? null : localStorage.getItem(STORAGE_KEY); + if (raw) stored = JSON.parse(raw) as Partial; + } 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(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; +} diff --git a/lib/desktop/system-accent.ts b/lib/desktop/system-accent.ts new file mode 100644 index 000000000..49f575429 --- /dev/null +++ b/lib/desktop/system-accent.ts @@ -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); +} diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 5c60da8bf..f9eb6109b 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -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; /** 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; +} + +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; + +/** 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) => 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; + /** The strip that stands in for a menu bar in a window with no native bar. */ + bar?: { + entries: () => Promise; + /** 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; +} + +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; } 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) => () => void; + onFlushRequest: (handler: () => Promise) => () => 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; diff --git a/lib/desktop/window-restore.ts b/lib/desktop/window-restore.ts new file mode 100644 index 000000000..d09ed7038 --- /dev/null +++ b/lib/desktop/window-restore.ts @@ -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(); + +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(); +} diff --git a/lib/draft-composer-data.ts b/lib/draft-composer-data.ts new file mode 100644 index 000000000..82eebc52d --- /dev/null +++ b/lib/draft-composer-data.ts @@ -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, + })), + }; +} diff --git a/lib/popout/open.ts b/lib/popout/open.ts index e1d51ea37..3cee94b3d 100644 --- a/lib/popout/open.ts +++ b/lib/popout/open.ts @@ -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; diff --git a/lib/popout/window-role.ts b/lib/popout/window-role.ts index de20ee6cf..3b1efe642 100644 --- a/lib/popout/window-role.ts +++ b/lib/popout/window-role.ts @@ -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 { if (!window.closed) window.location.replace(withBasePath('/')); diff --git a/locales/ar/common.json b/locales/ar/common.json index 654025307..b19d3aeeb 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -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": "أكمل تسجيل الدخول في المتصفح", diff --git a/locales/ca/common.json b/locales/ca/common.json index 0a9eb6fb2..b4c960e21 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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", diff --git a/locales/cs/common.json b/locales/cs/common.json index 18f0c03ee..28c8b0968 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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", diff --git a/locales/da/common.json b/locales/da/common.json index 5578eeca0..e47034d0e 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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", diff --git a/locales/de/common.json b/locales/de/common.json index efb641f2b..ca3134e5d 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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", diff --git a/locales/en/common.json b/locales/en/common.json index 7728594e1..e178107a0 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/es/common.json b/locales/es/common.json index 5a2af15a4..92ae5cfc1 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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", diff --git a/locales/et/common.json b/locales/et/common.json index b5ce8e291..af5abde97 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -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", diff --git a/locales/fa/common.json b/locales/fa/common.json index 22a2c1d43..b25da5a9b 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -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": "ورود را در مرورگر کامل کنید", diff --git a/locales/fr/common.json b/locales/fr/common.json index e30800b16..a283bf2a2 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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", diff --git a/locales/he/common.json b/locales/he/common.json index 571f30e34..8ca0148b4 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -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": "יש להשלים את הכניסה בדפדפן", diff --git a/locales/hu/common.json b/locales/hu/common.json index 2ab59129e..0459a57b5 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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", diff --git a/locales/it/common.json b/locales/it/common.json index a861d3390..95db2212c 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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", diff --git a/locales/ja/common.json b/locales/ja/common.json index 0606617f1..78070a2f8 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -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": "ブラウザでサインインを完了してください", diff --git a/locales/ko/common.json b/locales/ko/common.json index 7e0daa574..96a87153f 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -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": "브라우저에서 로그인을 마치세요", diff --git a/locales/lv/common.json b/locales/lv/common.json index 05939fbc0..1ca156d61 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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ā", diff --git a/locales/mn/common.json b/locales/mn/common.json index dfa942d34..52cc39b48 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -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": "Нэвтрэлтээ хөтөч дээр дуусгана уу", diff --git a/locales/nb/common.json b/locales/nb/common.json index a0002acca..0d7083171 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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", diff --git a/locales/nl/common.json b/locales/nl/common.json index 5524a36da..b0e75ada3 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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", diff --git a/locales/pl/common.json b/locales/pl/common.json index fa5b5481e..eb4f9f8fc 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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", diff --git a/locales/pt/common.json b/locales/pt/common.json index 87a2e2ff1..5235f5317 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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", diff --git a/locales/ro/common.json b/locales/ro/common.json index 80c31fa28..5076823a5 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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", diff --git a/locales/ru/common.json b/locales/ru/common.json index 55510f4c8..05ce8b7b8 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -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": "Завершите вход в браузере", diff --git a/locales/sk/common.json b/locales/sk/common.json index 9e12b12d2..392915139 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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", diff --git a/locales/th/common.json b/locales/th/common.json index 0be3db9db..0c34470ff 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -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": "ลงชื่อเข้าใช้ให้เสร็จในเบราว์เซอร์", diff --git a/locales/tr/common.json b/locales/tr/common.json index fa74d9864..5e5365633 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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", diff --git a/locales/uk/common.json b/locales/uk/common.json index 391a5c203..9d6aaed35 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -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": "Завершіть вхід у браузері", diff --git a/locales/vi/common.json b/locales/vi/common.json index 347fe2cd0..2076cdb65 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -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", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 97dacbb06..53eefab4f 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -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": "請在瀏覽器中完成登入", diff --git a/locales/zh/common.json b/locales/zh/common.json index de9e40f3c..770f679eb 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -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": "请在浏览器中完成登录",