From e20aded171fadd55d0ae66a368d45191dd025c90 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:53:40 +0100 Subject: [PATCH] fix: keep restored desktop composers off sent mail and on their own From, check links from the native menu, and fetch no dictionary without asking A restored composer opened whatever the id named. Sending keeps the id, so its autosave would have destroyed the Sent copy, Send mailed it twice and Discard deleted it. It now needs the login in its address and a real draft, keeps its From with several accounts, and stays on the list if the server doesn't answer. A message from a bulwark:// link no longer marks itself read or sends a receipt. Native menus speak all 30 languages. Spellcheck dictionaries come from Google only after a yes, never in no-third-party mode. --- .../desktop/__tests__/desktop-host.test.tsx | 168 +++++++++++++++++- components/desktop/desktop-host.tsx | 102 +++++++++-- .../email/__tests__/viewer-mark-read.test.tsx | 8 + .../__tests__/viewer-send-failures.test.tsx | 8 + components/email/email-composer.tsx | 14 +- components/email/email-viewer.tsx | 10 +- .../__tests__/popout-compose-app.test.tsx | 144 +++++++++++++-- .../__tests__/popout-message-app.test.tsx | 15 ++ components/popout/popout-compose-app.tsx | 118 ++++++++++-- components/popout/popout-message-app.tsx | 9 +- components/pro/pro-email-tab-body.tsx | 5 +- components/ui/context-menu.tsx | 16 +- lib/__tests__/outgoing-hosts-mode.test.ts | 4 + lib/admin/outgoing-hosts-registry.ts | 10 ++ lib/compose-flush-registry.ts | 16 ++ lib/desktop/__tests__/native-ui.test.tsx | 39 +++- lib/desktop/dictionary-download.ts | 14 ++ lib/desktop/languages.ts | 128 +++++++++++++ lib/desktop/native-menu.ts | 60 ++++++- lib/desktop/open-link.ts | 22 +++ lib/desktop/preferences.ts | 5 +- lib/desktop/types.ts | 15 +- lib/draft-composer-data.ts | 17 +- lib/outgoing-hosts.ts | 1 + locales/ar/common.json | 79 ++++++++ locales/ca/common.json | 79 ++++++++ locales/cs/common.json | 79 ++++++++ locales/da/common.json | 79 ++++++++ locales/de/common.json | 79 ++++++++ locales/en/common.json | 79 ++++++++ locales/es/common.json | 79 ++++++++ locales/et/common.json | 79 ++++++++ locales/fa/common.json | 79 ++++++++ locales/fr/common.json | 79 ++++++++ locales/he/common.json | 79 ++++++++ locales/hu/common.json | 79 ++++++++ locales/it/common.json | 79 ++++++++ locales/ja/common.json | 79 ++++++++ locales/ko/common.json | 79 ++++++++ locales/lv/common.json | 79 ++++++++ locales/mn/common.json | 79 ++++++++ locales/nb/common.json | 79 ++++++++ locales/nl/common.json | 79 ++++++++ locales/pl/common.json | 79 ++++++++ locales/pt/common.json | 79 ++++++++ locales/ro/common.json | 79 ++++++++ locales/ru/common.json | 79 ++++++++ locales/sk/common.json | 79 ++++++++ locales/th/common.json | 79 ++++++++ locales/tr/common.json | 79 ++++++++ locales/uk/common.json | 79 ++++++++ locales/vi/common.json | 79 ++++++++ locales/zh-TW/common.json | 79 ++++++++ locales/zh/common.json | 79 ++++++++ 54 files changed, 3246 insertions(+), 72 deletions(-) create mode 100644 lib/desktop/dictionary-download.ts create mode 100644 lib/desktop/languages.ts create mode 100644 lib/desktop/open-link.ts diff --git a/components/desktop/__tests__/desktop-host.test.tsx b/components/desktop/__tests__/desktop-host.test.tsx index b44514e6f..9f6da218b 100644 --- a/components/desktop/__tests__/desktop-host.test.tsx +++ b/components/desktop/__tests__/desktop-host.test.tsx @@ -2,7 +2,10 @@ 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 { readDesktopPrefs, resetDesktopPrefsCache, setDesktopPref } from '@/lib/desktop/preferences'; +import { resetDesktopLanguages } from '@/lib/desktop/languages'; +import { registerComposeFlush, resetComposeFlushRegistry } from '@/lib/compose-flush-registry'; +import { useToastStore } from '@/stores/toast-store'; import { useAuthStore } from '@/stores/auth-store'; import { useLocaleStore } from '@/stores/locale-store'; import { useOfflineStore } from '@/lib/offline/state'; @@ -13,36 +16,65 @@ 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() })); +vi.mock('@/hooks/use-device-policy', () => ({ useStrictestDevices: () => strictest.current })); +const strictest = vi.hoisted(() => ({ current: {} as object })); + +const resolveExternalLink = vi.fn(async (ctx: { href: string }) => ctx.href as string | null); +vi.mock('@/lib/plugin-hook-result', () => ({ resolveExternalLink: (ctx: { href: string }) => resolveExternalLink(ctx) })); +const requestInternalMailto = vi.fn((_raw: string) => true); +vi.mock('@/lib/protocol-handlers/mailto', () => ({ requestInternalMailto: (raw: string) => requestInternalMailto(raw) })); +const allowed = vi.hoisted(() => ({ dictionary: true })); +vi.mock('@/lib/desktop/dictionary-download', () => ({ dictionaryDownloadAllowed: () => allowed.dictionary })); +const signOut = vi.hoisted(() => ({ full: [] as (() => void)[], before: [] as (() => Promise)[] })); +vi.mock('@/lib/sign-out-cleanup', async (original) => ({ + ...(await original()), + onFullSignOut: (task: () => void) => signOut.full.push(task), + onBeforeSignOut: (task: () => Promise) => signOut.before.push(task), +})); const { DesktopHost } = await import('../desktop-host'); function fakeBridge() { let command: ((id: string) => void) | null = null; let flush: (() => Promise) | null = null; + let missing: ((language: string) => void) | null = null; const parts = { setState: vi.fn(), setLanguages: vi.fn(), sessionReady: vi.fn(), setRestoreEnabled: vi.fn(), setTheme: vi.fn(), + openExternal: vi.fn(), + allowDownload: vi.fn(), + clearFiles: 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 }, + shell: { openExternal: parts.openExternal, setLanguages: parts.setLanguages }, + spellcheck: { missing: async () => [], onMissing: (listener: (language: string) => void) => { missing = listener; return () => {}; }, allowDownload: parts.allowDownload }, + clipboard: { copyFile: async () => true, clearFiles: parts.clearFiles }, 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!() }; + return { ...parts, run: (id: string) => act(() => command!(id)), flush: () => flush!(), missing: (language: string) => act(() => missing!(language)) }; } const original = useAuthStore.getState(); beforeEach(() => { push.mockClear(); + resolveExternalLink.mockClear(); + resolveExternalLink.mockImplementation(async (ctx) => ctx.href); + requestInternalMailto.mockClear(); + requestInternalMailto.mockReturnValue(true); + allowed.dictionary = true; + signOut.full.length = 0; + signOut.before.length = 0; + resetDesktopLanguages(); canPrint.mockReturnValue(true); canDownload.mockReturnValue(true); localStorage.clear(); @@ -141,6 +173,136 @@ describe('DesktopHost', () => { expect(shell.setLanguages.mock.lastCall![0]).toMatchObject({ ui: 'de' }); }); + describe('Open Link in a native menu', () => { + const openLink = (url: string) => `link.open:${JSON.stringify({ url, text: 'click' })}`; + + it('goes past the same check as a click on it', async () => { + const shell = fakeBridge(); + render(); + shell.run(openLink('https://example.org/a')); + await waitFor(() => expect(shell.openExternal).toHaveBeenCalledWith('https://example.org/a')); + expect(resolveExternalLink).toHaveBeenCalledWith(expect.objectContaining({ href: 'https://example.org/a', text: 'click' })); + }); + + it('a plugin can stop it', async () => { + resolveExternalLink.mockResolvedValue(null); + const shell = fakeBridge(); + render(); + shell.run(openLink('https://example.org/a')); + await waitFor(() => expect(resolveExternalLink).toHaveBeenCalled()); + await act(async () => {}); + expect(shell.openExternal).not.toHaveBeenCalled(); + }); + + it('a plugin can send it elsewhere', async () => { + resolveExternalLink.mockResolvedValue('https://safe.example.org/'); + const shell = fakeBridge(); + render(); + shell.run(openLink('https://example.org/a')); + await waitFor(() => expect(shell.openExternal).toHaveBeenCalledWith('https://safe.example.org/')); + }); + + it('a mail address goes to the app\'s composer', async () => { + const shell = fakeBridge(); + render(); + shell.run(openLink('mailto:a@example.org')); + await waitFor(() => expect(requestInternalMailto).toHaveBeenCalledWith('mailto:a@example.org')); + expect(shell.openExternal).not.toHaveBeenCalled(); + }); + + it('tells the shell it has this path, so an older page keeps the direct way', async () => { + const shell = fakeBridge(); + render(); + await waitFor(() => expect(shell.setState).toHaveBeenCalled()); + expect(shell.setState.mock.lastCall![0]).toMatchObject({ 'link.check': true }); + }); + }); + + it('hands the shell its menus in the interface language', async () => { + const shell = fakeBridge(); + act(() => useLocaleStore.setState({ locale: 'fr' })); + render(); + await waitFor(() => expect(shell.setLanguages).toHaveBeenCalled()); + const { labels } = shell.setLanguages.mock.lastCall![0]; + // The test's next-intl gives the key back. + expect(labels.menuFile).toBe('desktopUi.menu.file'); + expect(labels.menuReply).toBe('context_menu.reply'); + expect(labels.unsentMany).toBe('desktopUi.closing.unsent_many'); + }); + + describe('a language with no checker in the OS', () => { + afterEach(() => resetComposeFlushRegistry()); + const toasts = () => useToastStore.getState().toasts; + + it('the composer asks once, and only a yes opens the downloads', async () => { + useToastStore.setState({ toasts: [] }); + const shell = fakeBridge(); + render(); + await act(async () => {}); + shell.missing('nl-NL'); + // No composer open: nobody is writing yet. + expect(toasts()).toHaveLength(0); + const stop = registerComposeFlush(async () => {}); + await waitFor(() => expect(toasts()).toHaveLength(1)); + expect(shell.allowDownload).not.toHaveBeenCalled(); + act(() => toasts()[0].action!.onClick()); + expect(shell.allowDownload).toHaveBeenCalledTimes(1); + expect(readDesktopPrefs().dictionaryDownload).toBe('yes'); + stop(); + }); + + it('never a second time', async () => { + useToastStore.setState({ toasts: [] }); + setDesktopPref('dictionaryDownload', 'no'); + const shell = fakeBridge(); + render(); + await act(async () => {}); + const stop = registerComposeFlush(async () => {}); + shell.missing('nl-NL'); + await act(async () => {}); + expect(toasts()).toHaveLength(0); + stop(); + }); + + it('never in no-third-party mode, even after an old yes', async () => { + useToastStore.setState({ toasts: [] }); + allowed.dictionary = false; + setDesktopPref('dictionaryDownload', 'yes'); + const first = fakeBridge(); + const view = render(); + await act(async () => {}); + expect(first.allowDownload).not.toHaveBeenCalled(); + view.unmount(); + setDesktopPref('dictionaryDownload', 'ask'); + const shell = fakeBridge(); + render(); + await act(async () => {}); + const stop = registerComposeFlush(async () => {}); + shell.missing('nl-NL'); + await act(async () => {}); + expect(toasts()).toHaveLength(0); + stop(); + }); + + it('a yes from before opens them at the next start', async () => { + setDesktopPref('dictionaryDownload', 'yes'); + const shell = fakeBridge(); + render(); + await act(async () => {}); + expect(shell.allowDownload).toHaveBeenCalledTimes(1); + }); + }); + + it('signing out takes the files copied to the clipboard with it', async () => { + const shell = fakeBridge(); + render(); + await act(async () => {}); + signOut.full[0](); + expect(shell.clearFiles).toHaveBeenCalledTimes(1); + await signOut.before[0](); + expect(shell.clearFiles).toHaveBeenCalledTimes(2); + }); + it('a quit is told how much mail is waiting', async () => { const shell = fakeBridge(); render(); diff --git a/components/desktop/desktop-host.tsx b/components/desktop/desktop-host.tsx index 83c6722e3..f8295a3ad 100644 --- a/components/desktop/desktop-host.tsx +++ b/components/desktop/desktop-host.tsx @@ -1,22 +1,30 @@ "use client"; import { useEffect, useRef } from "react"; +import { useTranslations } from "next-intl"; +import { useStrictestDevices } from "@/hooks/use-device-policy"; import { useRouter } from "@/i18n/navigation"; import { openKeyboardShortcuts, parkAppCommand, requestAppCommand, type AppCommand } from "@/lib/app-commands"; +import { onComposerOpened, composerIsOpen } from "@/lib/compose-flush-registry"; import { getDesktopBridge } from "@/lib/desktop/bridge"; import { runDesktopCommand, setDesktopBaseState } from "@/lib/desktop/commands"; -import { useDesktopPrefs } from "@/lib/desktop/preferences"; +import { dictionaryDownloadAllowed } from "@/lib/desktop/dictionary-download"; +import { noteComposerLanguage, setDesktopInterfaceLanguage, shellLabels } from "@/lib/desktop/languages"; +import { openLinkFromNativeMenu } from "@/lib/desktop/open-link"; +import { readDesktopPrefs, setDesktopPref, 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 { onBeforeSignOut, onFullSignOut } from "@/lib/sign-out-cleanup"; import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-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"; +import { toast } from "@/stores/toast-store"; /** * The webmail's side of what every desktop window needs: runs menu commands nothing @@ -28,13 +36,15 @@ export function DesktopHost() { const router = useRouter(); const routerRef = useRef(router); routerRef.current = router; + const t = useTranslations(); + const tRef = useRef(t); + tRef.current = t; 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 strictest = useStrictestDevices(); const { restoreWindows } = useDesktopPrefs(); const theme = useThemeStore((s) => s.theme); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); @@ -68,6 +78,16 @@ export function DesktopHost() { routerRef.current.push(`/mail/folder/${encodeURIComponent(id.slice("link.folder:".length))}`); return; } + // Open Link in a native menu. + if (id.startsWith("link.open:")) { + try { + const { url, text } = JSON.parse(id.slice("link.open:".length)) as { url: string; text: string }; + void openLinkFromNativeMenu(url, text, useEmailStore.getState().selectedEmail?.id); + } catch { + // Only the shell sends this, and it sends JSON. + } + return; + } if (!runDesktopCommand(id)) fallback[id]?.(); }); }, []); @@ -79,6 +99,7 @@ export function DesktopHost() { const signedIn = isAuthenticated; const popout = isPopoutWindow(); setDesktopBaseState({ + "link.check": true, "shortcuts.singleKeys": singleKeys, "policy.download": canDownloadAnywhere(), "file.print": canPrint, @@ -92,24 +113,79 @@ export function DesktopHost() { "go.files": signedIn, "help.shortcuts": true, }); - }, [isAuthenticated, singleKeys, policyLoaded, accounts]); + }, [isAuthenticated, singleKeys, strictest]); - // Menus and spellcheck follow the interface, and the composer's language too. + // Menus and spellcheck follow the interface. 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]); + if (ui) setDesktopInterfaceLanguage(ui, shellLabels(tRef.current as unknown as (key: string) => string)); + }, [uiLocale]); + + // What the user writes in, whichever composer asks for it too. + useEffect(() => { + noteComposerLanguage("settings", writingLanguage && writingLanguage !== "auto" ? writingLanguage : null); + }, [writingLanguage]); + + // A language with no checker in the OS needs a dictionary from Google: the composer + // asks once, and never in no-third-party mode. Until a yes, the shell has nowhere to + // fetch from at all. + useEffect(() => { + const spellcheck = getDesktopBridge()?.spellcheck; + if (!spellcheck) return; + if (readDesktopPrefs().dictionaryDownload === "yes" && dictionaryDownloadAllowed()) spellcheck.allowDownload(); + const waiting = new Set(); + const offer = () => { + const [language] = waiting; + if (!language || !composerIsOpen() || !dictionaryDownloadAllowed()) return; + if (readDesktopPrefs().dictionaryDownload !== "ask") return; + // Asked once, whatever comes of it. + setDesktopPref("dictionaryDownload", "no"); + const translate = tRef.current; + let name = language; + try { + name = new Intl.DisplayNames([document.documentElement.lang || "en"], { type: "language" }).of(language) ?? language; + } catch { + // The tag itself will do. + } + toast.info(translate("desktopUi.spellcheck.offer", { language: name }), { + duration: 0, + action: { + label: translate("desktopUi.spellcheck.download"), + onClick: () => { + setDesktopPref("dictionaryDownload", "yes"); + if (dictionaryDownloadAllowed()) spellcheck.allowDownload(); + }, + }, + }); + }; + const heard = (language: string) => { + waiting.add(language); + offer(); + }; + void spellcheck.missing().then((list) => list.forEach(heard)); + const stopMissing = spellcheck.onMissing(heard); + const stopComposer = onComposerOpened(offer); + return () => { + stopMissing(); + stopComposer(); + }; + }, []); + + // Signed out: nothing of that account stays on the file clipboard. + useEffect(() => { + const clipboard = getDesktopBridge()?.clipboard; + if (!clipboard?.clearFiles) return; + const clear = () => clipboard.clearFiles?.(); + onFullSignOut(clear); + onBeforeSignOut(async () => clear()); + }, []); // 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. + // Signed in: this window has a session to lend, for pop-outs from last time and links. useEffect(() => { if (!isAuthenticated || isPopoutWindow()) return; getDesktopBridge()?.windows?.sessionReady?.(); diff --git a/components/email/__tests__/viewer-mark-read.test.tsx b/components/email/__tests__/viewer-mark-read.test.tsx index 86e0f4dd8..569b0c285 100644 --- a/components/email/__tests__/viewer-mark-read.test.tsx +++ b/components/email/__tests__/viewer-mark-read.test.tsx @@ -75,6 +75,14 @@ describe('EmailViewer mark as read', () => { await waitFor(() => expect(onMarkAsRead).toHaveBeenCalledWith('e1', true, { automatic: true })); }); + it('a message opened from a link stays unread until the user says', async () => { + useSettingsStore.setState({ markAsReadDelay: 0 }); + const onMarkAsRead = vi.fn(); + render(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(onMarkAsRead).not.toHaveBeenCalled(); + }); + it("passes the toolbar button on as the user's own change", () => { useSettingsStore.setState({ markAsReadDelay: -1 }); const onMarkAsRead = vi.fn(); diff --git a/components/email/__tests__/viewer-send-failures.test.tsx b/components/email/__tests__/viewer-send-failures.test.tsx index 759465667..fd95d5e73 100644 --- a/components/email/__tests__/viewer-send-failures.test.tsx +++ b/components/email/__tests__/viewer-send-failures.test.tsx @@ -271,6 +271,14 @@ describe('viewer send failures', () => { useAuthStore.setState({ client: null, identities: [] } as never); }); + it('a message opened from a link sends none by itself', async () => { + render(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + expect(sendReadReceipt).not.toHaveBeenCalled(); + }); + it('tries once per opening, not once per refresh, when the quota is full on Stalwart 1.0', async () => { const props = { mailboxes, selectedMailbox: 'inbox', currentMailboxRole: 'inbox' }; const { rerender } = render(); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 67be255df..b3037cede 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -170,6 +170,7 @@ 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 { noteComposerLanguage } from "@/lib/desktop/languages"; import { noteOpenDraft } from "@/lib/desktop/window-restore"; import type { RescuedDraft } from "@/lib/popout/channel"; import { toUnicodeEmail } from "@/lib/idn"; @@ -385,7 +386,7 @@ interface EmailComposerProps { /** The state as the window unloads, for a reload to start from. */ onUnloadState?: (data: ComposerDraftData) => void; /** A save reached the server, for other windows showing the Drafts folder. */ - onDraftSaved?: () => void; + onDraftSaved?: (draftId: string) => void; /** * Hands a draft to another window to save as this one closes: a request * started now wouldn't survive. False when that can't be done safely. @@ -1948,7 +1949,7 @@ export function EmailComposer({ // 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 }); + if (draftIdRef.current) noteOpenDraft(effectiveSessionKey, { draftId: draftIdRef.current, localAccountId: draftLocalAccountIdRef.current ?? useAuthStore.getState().activeAccountId, ownerAccountId: draftAccountIdRef.current }); return () => noteOpenDraft(effectiveSessionKey, null); // eslint-disable-next-line react-hooks/exhaustive-deps }, [effectiveSessionKey]); @@ -3028,7 +3029,7 @@ export function EmailComposer({ 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 }); + noteOpenDraft(effectiveSessionKey, { draftId: savedDraftId, localAccountId: savingLogin ?? useAuthStore.getState().activeAccountId, ownerAccountId: draftAccountId }); setDraftId(savedDraftId); setDraftAccountId(draftAccountId); setDraftLocalAccountId(savingLogin); @@ -3109,7 +3110,7 @@ export function EmailComposer({ // Only the save the user asked for is read out: autosave would say it // every minute, and the save before sending is followed by "Sent". if (opts.announce && id && !saveFailedRef.current) announceStatus(t('draft_saved')); - if (id && !saveFailedRef.current) ownWindowRef.current?.onDraftSaved?.(); + if (id && !saveFailedRef.current) ownWindowRef.current?.onDraftSaved?.(id); return id; })(); inflightSaveRef.current = promise; @@ -3372,6 +3373,11 @@ export function EmailComposer({ const uiLocale = useLocale(); const writingLanguage = useSettingsStore((s) => s.writingLanguage); const textLang = writingLanguage && writingLanguage !== 'auto' ? writingLanguage : uiLocale; + // The desktop app's spellcheck follows what's being written. + useEffect(() => { + noteComposerLanguage(effectiveSessionKey, textLang); + return () => noteComposerLanguage(effectiveSessionKey, null); + }, [effectiveSessionKey, textLang]); const proofreading = useProofreading({ composerId: pluginComposerId, sessionKey: effectiveSessionKey, diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index cd85eacd2..d4fd058dd 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -290,6 +290,11 @@ interface EmailViewerProps { onToggleFullscreen?: () => void; /** Opens this message in its own window; left out where that isn't on offer. */ onOpenInWindow?: () => void; + /** + * Opened by something outside the app (a bulwark:// link), not by the user in it. + * It shows the message and does nothing on its own: no mark as read, no read receipt. + */ + noAutomaticActions?: boolean; isFullscreen?: boolean; currentUserEmail?: string; currentUserName?: string; @@ -1324,6 +1329,7 @@ export function EmailViewer({ onCompose, onToggleFullscreen, onOpenInWindow, + noAutomaticActions = false, isFullscreen = false, currentUserEmail, currentUserName, @@ -2200,7 +2206,7 @@ export function EmailViewer({ useEffect(() => { // Mark as read when email is viewed, respecting the delay setting - if (!email || !onMarkAsRead) { + if (!email || !onMarkAsRead || noAutomaticActions) { return; } @@ -3948,7 +3954,7 @@ ${printWatermark ? printWatermark.html : ''} if (autoMdnRef.current !== email?.id) autoMdnRef.current = null; }, [email?.id]); useEffect(() => { - if (readReceiptResponse !== 'always') return; + if (readReceiptResponse !== 'always' || noAutomaticActions) return; if (!shouldOfferReadReceipt || !email) return; if (autoMdnRef.current === email.id) return; autoMdnRef.current = email.id; diff --git a/components/popout/__tests__/popout-compose-app.test.tsx b/components/popout/__tests__/popout-compose-app.test.tsx index 2a02faf0f..7fb96e09b 100644 --- a/components/popout/__tests__/popout-compose-app.test.tsx +++ b/components/popout/__tests__/popout-compose-app.test.tsx @@ -1,10 +1,12 @@ -import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel'; import { openPopoutChannel, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel'; import { createComposeHandoff, rememberComposePayload } from '@/lib/popout/compose-handoff'; import { useAuthStore } from '@/stores/auth-store'; +import { useIdentityStore } from '@/stores/identity-store'; import { usePluginStore } from '@/stores/plugin-store'; +import { emailHooks } from '@/lib/plugin-hooks'; // The composer's window: when it tells the opening window to let go, and what // happens on a reload. The composer itself is stubbed; its own tests cover it. @@ -25,9 +27,29 @@ vi.mock('@/components/error', () => ({ ErrorBoundary: ({ children }: { children: vi.mock('../popout-shell', () => ({ PopoutShell: ({ children }: { children: React.ReactNode }) => <>{children}, PopoutSkeleton: () =>
, + PopoutNotice: ({ title, children }: { title: string; children: React.ReactNode }) =>

{title}

{children}
, })); vi.mock('@/hooks/use-document-title', () => ({ useDocumentTitle: vi.fn() })); +const identityMocks = vi.hoisted(() => ({ + multi: { enabled: false, allIdentities: [] as unknown[], groups: [] as unknown[] }, + bump: undefined as undefined | (() => void), +})); +vi.mock('@/hooks/use-pro-multi-account-identities', async () => { + const { useReducer } = await import('react'); + return { + useProMultiAccountIdentities: () => { + const [, bump] = useReducer((n: number) => n + 1, 0); + identityMocks.bump = bump; + return identityMocks.multi; + }, + stripCrossAccountIdentityPrefix: (id: string) => { + const at = id.indexOf('::'); + return at >= 0 ? { localAccountId: id.slice(0, at), rawId: id.slice(at + 2) } : { localAccountId: null, rawId: id }; + }, + }; +}); + const { PopoutComposeApp } = await import('../popout-compose-app'); const original = useAuthStore.getState(); @@ -69,25 +91,40 @@ function openAt(handoffId: string) { describe('a composer coming back on its draft', () => { const closeSelf = vi.fn(); + const setRestore = vi.fn(); + const LOGIN = 'alice@example.org@mail.example.org'; + const MAILBOXES = [ + { id: 'mDrafts', role: 'drafts' }, + { id: 'mSent', role: 'sent' }, + { id: 'mTrash', role: 'trash' }, + { id: 'mInbox', role: 'inbox' }, + ]; beforeEach(() => { closeSelf.mockClear(); - window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { closeSelf } } as never; + setRestore.mockClear(); + identityMocks.multi = { enabled: false, allIdentities: [], groups: [] }; + useIdentityStore.setState({ identities: [{ id: 'id-1', email: 'me@example.org', name: 'Me' }] } as never); + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { closeSelf, setRestore } } as never; }); afterEach(() => { window.bulwarkDesktop = undefined; + vi.useRealTimers(); }); const draft = { - id: 'd1', subject: 'Half-written', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ email: 'me@example.org' }], + id: 'd1', subject: 'Half-written', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ name: 'Me', email: 'me@example.org' }], + keywords: { $draft: true }, mailboxIds: { mDrafts: true }, bodyValues: { t: { value: 'Text' } }, textBody: [{ partId: 't', type: 'text/plain' }], htmlBody: [], attachments: [{ blobId: 'b1', name: 'a.pdf', type: 'application/pdf', size: 3 }], }; - const openDraft = (getEmail: ReturnType, search = 'draft=d1&account=alice&owner=group') => { - useAuthStore.setState({ client: { getEmail } as never, getClientForAccount: () => undefined } as never); + const openDraft = (getEmail: ReturnType, search = `draft=d1&account=${encodeURIComponent(LOGIN)}&owner=group`) => { + const client = { getEmail, getMailboxes: vi.fn(async () => MAILBOXES) }; + useAuthStore.setState({ client: { other: true } as never, getClientForAccount: (id: string) => (id === LOGIN ? client : undefined) } as never); window.history.replaceState(null, '', `/en/popout/compose?${search}`); render(); + return client; }; it('reads the draft from where it lives, and opens the composer on it, id and all', async () => { @@ -96,19 +133,106 @@ describe('a composer coming back on its draft', () => { await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Half-written')); expect(getEmail).toHaveBeenCalledWith('d1', 'group'); // Saving goes over this draft, not next to it, and its files stay. - expect(composer?.initialData).toMatchObject({ draftId: 'd1', draftAccountId: 'group', draftLocalAccountId: 'alice', to: 'a@example.org' }); + expect(composer?.initialData).toMatchObject({ draftId: 'd1', draftAccountId: 'group', draftLocalAccountId: LOGIN, to: 'a@example.org' }); expect((composer?.initialData as unknown as { attachments: unknown[] }).attachments).toHaveLength(1); }); - it('a draft that has gone since (sent elsewhere) closes the window', async () => { + // Sending keeps the id. A composer on it would destroy the Sent copy at the next + // autosave, Send would mail it twice, and Discard would delete it. + it.each([ + ['a message that was sent', { keywords: { $seen: true }, mailboxIds: { mSent: true } }], + ['a sent message still marked as a draft', { keywords: { $draft: true }, mailboxIds: { mSent: true } }], + ['a draft in the Trash', { keywords: { $draft: true }, mailboxIds: { mTrash: true } }], + ['mail in the Inbox that is no draft at all', { keywords: {}, mailboxIds: { mInbox: true } }], + ])('%s is not opened', async (_name, shape) => { + openDraft(vi.fn(async () => ({ ...draft, ...shape }))); + await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1)); + expect(screen.queryByTestId('composer')).toBeNull(); + }); + + it('a send brought back has no $draft, and in Drafts it opens', async () => { + openDraft(vi.fn(async () => ({ ...draft, keywords: {}, mailboxIds: { mDrafts: true } }))); + await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy()); + }); + + it('an address with no login opens nothing, not the active account\'s mail under the same id', async () => { + const getEmail = vi.fn(async () => draft); + useAuthStore.setState({ client: { getEmail, getMailboxes: async () => MAILBOXES } as never, getClientForAccount: () => undefined } as never); + window.history.replaceState(null, '', '/en/popout/compose?draft=d1'); + render(); + await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1)); + expect(getEmail).not.toHaveBeenCalled(); + }); + + it('a draft gone since closes the window', async () => { openDraft(vi.fn(async () => null)); await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1)); expect(screen.queryByTestId('composer')).toBeNull(); }); - it('a server that doesn\'t answer closes it too: the draft is safe on the server', async () => { - openDraft(vi.fn(async () => { throw new Error('offline'); })); - await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1)); + it('a server not answering keeps the window and the entry, and Try again works', async () => { + let up = false; + const getEmail = vi.fn(async () => { + if (!up) throw new Error('offline'); + return draft; + }); + openDraft(getEmail); + await screen.findByText('unavailable_title'); + expect(closeSelf).not.toHaveBeenCalled(); + // On the list for next time. + expect(setRestore.mock.lastCall![0]).toEqual([expect.stringContaining('draft=d1')]); + up = true; + fireEvent.click(screen.getByText('try_again')); + await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy()); + }); + + it('plugins get their say on the draft, as when it opens from the list', async () => { + const hook = emailHooks.onBeforeEditDraft.register('test', ((value: unknown) => ({ ...(value as object), subject: 'Changed by a plugin' })) as never); + try { + openDraft(vi.fn(async () => draft)); + await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Changed by a plugin')); + } finally { + hook.dispose(); + } + }); + + describe('with several accounts', () => { + const OTHER = { id: 'other@x@h::id-9', email: 'other@example.org', name: 'Other', localAccountId: LOGIN }; + + it('waits for the identity that wrote it, however late it loads, and starts on that', async () => { + identityMocks.multi = { enabled: true, allIdentities: [], groups: [] }; + const fromOther = { ...draft, from: [{ name: 'Other', email: 'other@example.org' }] }; + openDraft(vi.fn(async () => fromOther)); + await settle(); + // Not on the default in the meantime: it would save that. + expect(screen.queryByTestId('composer')).toBeNull(); + act(() => { + identityMocks.multi = { enabled: true, allIdentities: [OTHER], groups: [] }; + identityMocks.bump?.(); + }); + await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy()); + expect(composer?.initialData).toMatchObject({ selectedIdentityId: OTHER.id }); + expect((composer?.initialData as unknown as { fromOverrideEnabled?: boolean }).fromOverrideEnabled).toBeUndefined(); + }); + + it('an address no identity has any more stays as the From, not the default', async () => { + identityMocks.multi = { enabled: true, allIdentities: [], groups: [] }; + vi.useFakeTimers({ shouldAdvanceTime: true }); + openDraft(vi.fn(async () => ({ ...draft, from: [{ name: 'Gone', email: 'gone@example.org' }] }))); + await act(async () => { + await vi.advanceTimersByTimeAsync(4100); + }); + await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy()); + expect(composer?.initialData).toMatchObject({ selectedIdentityId: null, fromOverrideEnabled: true, fromOverrideEmail: 'gone@example.org', fromOverrideName: 'Gone' }); + }); + }); + + it('a save moves the address on, or a reload would look for a version that is gone', async () => { + openDraft(vi.fn(async () => draft)); + await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy()); + act(() => (composer as unknown as { ownWindow: { onDraftSaved: (id: string) => void } }).ownWindow.onDraftSaved('d2')); + expect(new URLSearchParams(window.location.search).get('draft')).toBe('d2'); + expect(new URLSearchParams(window.location.search).get('account')).toBe(LOGIN); }); }); diff --git a/components/popout/__tests__/popout-message-app.test.tsx b/components/popout/__tests__/popout-message-app.test.tsx index 8d877a955..82e6a7d69 100644 --- a/components/popout/__tests__/popout-message-app.test.tsx +++ b/components/popout/__tests__/popout-message-app.test.tsx @@ -78,6 +78,21 @@ afterEach(() => { window.history.replaceState(null, '', '/'); }); +describe('a message opened from a bulwark:// link', () => { + it('asks for nothing automatic: such a link may show a message, not act on it', async () => { + window.history.replaceState(null, '', '/en/popout/message/m%2F1?account=login-b&via=link'); + render(); + await settle(); + expect((view as unknown as { noAutomaticActions?: boolean }).noAutomaticActions).toBe(true); + }); + + it('an ordinary pop-out acts as ever', async () => { + render(); + await settle(); + expect((view as unknown as { noAutomaticActions?: boolean }).noAutomaticActions).toBeFalsy(); + }); +}); + describe('PopoutMessageApp', () => { it('opens the message of the address, through its login and account', async () => { render(); diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx index 7aaa4f709..11e02e952 100644 --- a/components/popout/popout-compose-app.tsx +++ b/components/popout/popout-compose-app.tsx @@ -18,9 +18,15 @@ import { announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watch import { acknowledgeComposeHandoff, receiveComposeHandoff, rememberComposePayload, rememberedComposePayload, watchHandoffCancel } from "@/lib/popout/compose-handoff"; import type { ComposePopoutPayload } from "@/lib/popout/compose-payload"; import { closePopoutWindow } from "@/lib/popout/window-role"; -import { composerDataFromDraft } from "@/lib/draft-composer-data"; +import { Button } from "@/components/ui/button"; +import { composerDataFromDraft, isRestorableDraft } from "@/lib/draft-composer-data"; +import { noteOpenDraft } from "@/lib/desktop/window-restore"; +import { findDraftIdentityIdForLogin } from "@/lib/draft-source"; +import { emailHooks } from "@/lib/plugin-hooks"; +import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; import { useIdentityStore } from "@/stores/identity-store"; -import { PopoutShell, PopoutSkeleton } from "./popout-shell"; +import type { Email } from "@/lib/jmap/types"; +import { PopoutNotice, PopoutShell, PopoutSkeleton } from "./popout-shell"; /** A warning with no main window left to show it stays this long before the window goes. */ const WARNING_READ_MS = 8000; @@ -44,9 +50,20 @@ function readLaunch(): { handoffId: string | null; accountId?: string; draftId?: }; } +/** How long a restored draft waits for the identity that wrote it to load. */ +const IDENTITY_WAIT_MS = 4000; + /** * The composer that was open when the desktop app quit, back on its draft. A draft * that has gone since (sent or deleted elsewhere) means there's nothing to show. + * A server that doesn't answer says nothing about it: the window stays, offers to try + * again, and the draft stays on the list for the next start. + * + * Only through the login the address names, and never without one: ids repeat across + * accounts, so another login could hold someone else's message under this id. + * + * The From stays what it was. With several accounts the other accounts' identities + * load later, and the composer would otherwise start on the default and save that. */ function RestoredDraft({ draftId, accountId, ownerAccountId, onReady }: { draftId: string; @@ -55,37 +72,92 @@ function RestoredDraft({ draftId, accountId, ownerAccountId, onReady }: { onReady: (payload: ComposePopoutPayload) => void; }) { const t = useTranslations(); + const tRestore = useTranslations("desktopUi.restore"); const tWindows = useTranslations("pwaWindows"); - const client = useAuthStore((s) => (accountId ? s.getClientForAccount(accountId) : undefined) ?? s.client); - const identities = useIdentityStore((s) => s.identities); + const client = useAuthStore((s) => (accountId ? s.getClientForAccount(accountId) : undefined)); + const ownIdentities = useIdentityStore((s) => s.identities); + const multi = useProMultiAccountIdentities(); + const identities = multi.enabled ? multi.allIdentities : ownIdentities; + const [draft, setDraft] = useState(null); + const [failed, setFailed] = useState(false); + const [attempt, setAttempt] = useState(0); + const [waitOver, setWaitOver] = useState(false); + + // Until the composer takes over, this window still stands for the draft. useEffect(() => { + const key = `restored-${draftId}`; + noteOpenDraft(key, { draftId, localAccountId: accountId, ownerAccountId }); + return () => noteOpenDraft(key, null); + }, [draftId, accountId, ownerAccountId]); + + useEffect(() => { + if (!accountId) { + closePopoutWindow(); + return; + } if (!client) return; let cancelled = false; - void client.getEmail(draftId, ownerAccountId).then((draft) => { + setFailed(false); + void Promise.all([client.getEmail(draftId, ownerAccountId), client.getMailboxes(ownerAccountId)]).then(async ([found, mailboxes]) => { if (cancelled) return; - if (!draft) { + if (!found || !isRestorableDraft(found, mailboxes)) { closePopoutWindow(); return; } - onReady({ - mode: "compose", - initialData: composerDataFromDraft(draft, { - accountId: ownerAccountId, - localAccountId: accountId, - identities, - noSubject: t("email_composer.no_subject"), - }), - }); + const transformed = await emailHooks.onBeforeEditDraft.transform(found); + if (!cancelled) setDraft(transformed); }).catch((error) => { debug.error("Failed to reopen a draft:", error); - if (!cancelled) closePopoutWindow(); + if (!cancelled) setFailed(true); }); return () => { cancelled = true; }; - // Once per client: the draft doesn't change under a window still opening. + }, [client, accountId, draftId, ownerAccountId, attempt]); + + // Back online: try by itself. + useEffect(() => { + if (!failed) return; + const retry = () => setAttempt((n) => n + 1); + window.addEventListener("online", retry); + return () => window.removeEventListener("online", retry); + }, [failed]); + + useEffect(() => { + const timer = setTimeout(() => setWaitOver(true), IDENTITY_WAIT_MS); + return () => clearTimeout(timer); + }, []); + + useEffect(() => { + if (!draft) return; + const from = draft.from?.[0]; + const matched = findDraftIdentityIdForLogin(identities, from, accountId); + // The identity may still be on its way: wait for it, then keep the address as it was. + if (!matched && from && !waitOver) return; + const data = composerDataFromDraft(draft, { + accountId: ownerAccountId, + localAccountId: accountId, + identities, + noSubject: t("email_composer.no_subject"), + }); + onReady({ + mode: "compose", + initialData: !matched && from?.email + ? { ...data, fromOverrideEnabled: true, fromOverrideEmail: from.email, fromOverrideName: from.name ?? "" } + : data, + }); + // onReady swaps this component out; `t` changes nothing here. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [client, draftId, ownerAccountId]); + }, [draft, identities, waitOver]); + + if (failed) { + return ( + + + + + ); + } return ; } @@ -205,7 +277,15 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; onUnloadState: (state: ComposerDraftData) => { if (handoffId) rememberComposePayload(handoffId, { ...payload, initialData: state, prefill: null }); }, - onDraftSaved: () => announceMailChanged(useAuthStore.getState().activeAccountId), + onDraftSaved: (draftId: string) => { + announceMailChanged(useAuthStore.getState().activeAccountId); + // A save makes a new id, and this address is what a reload starts from. + const url = new URL(window.location.href); + if (url.searchParams.has("draft")) { + url.searchParams.set("draft", draftId); + window.history.replaceState(null, "", `${url.pathname}${url.search}`); + } + }, }), [handoffId, mainWindow, payload]); return ( diff --git a/components/popout/popout-message-app.tsx b/components/popout/popout-message-app.tsx index c7841a2b1..4f0ae12ad 100644 --- a/components/popout/popout-message-app.tsx +++ b/components/popout/popout-message-app.tsx @@ -24,7 +24,7 @@ const CHECK_INTERVAL_MS = 60_000; * The id from the address bar: a page the service worker kept for a start * without the network was rendered for another id. */ -function readLaunch(paramId: string): { emailId: string; accountId?: string; ownerAccountId?: string } { +function readLaunch(paramId: string): { emailId: string; accountId?: string; ownerAccountId?: string; fromLink?: boolean } { if (typeof window === "undefined") return { emailId: paramId }; const segments = window.location.pathname.split("/").filter(Boolean); const at = segments.lastIndexOf("message"); @@ -35,7 +35,7 @@ function readLaunch(paramId: string): { emailId: string; accountId?: string; own // A malformed address: the route's own value. } const search = new URLSearchParams(window.location.search); - return { emailId, accountId: search.get("account") ?? undefined, ownerAccountId: search.get("owner") ?? undefined }; + return { emailId, accountId: search.get("account") ?? undefined, ownerAccountId: search.get("owner") ?? undefined, fromLink: search.get("via") === "link" }; } type Presence = "present" | "gone"; @@ -96,7 +96,7 @@ function useMessagePresence(emailId: string, ownerAccountId: string | undefined) return { presence, mailboxIds, liveState }; } -function PopoutMessage({ emailId, accountId, ownerAccountId }: { emailId: string; accountId?: string; ownerAccountId?: string }) { +function PopoutMessage({ emailId, accountId, ownerAccountId, fromLink }: { emailId: string; accountId?: string; ownerAccountId?: string; fromLink?: boolean }) { const t = useTranslations("pwaWindows"); const tSidebar = useTranslations("sidebar"); const login = useAuthStore((s) => accountId ?? s.activeAccountId ?? undefined); @@ -194,6 +194,7 @@ function PopoutMessage({ emailId, accountId, ownerAccountId }: { emailId: string onOpenCompose={openCompose} liveState={liveState} onMailChanged={handleMailChanged} + noAutomaticActions={fromLink} className="min-h-0 w-full flex-1" /> @@ -205,7 +206,7 @@ export function PopoutMessageApp({ id }: { id: string }) { const [launch] = useState(() => readLaunch(id)); return ( - + ); } diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index aeaf0f204..4a8adc45f 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -79,6 +79,8 @@ interface ProEmailViewProps { liveState?: Pick | null; /** This view changed the message's flags, for a host other windows should hear from. */ onMailChanged?: () => void; + /** See EmailViewer. */ + noAutomaticActions?: boolean; /** * The message left this view: deleted, archived, moved, or reopened in the * composer. The host decides what that means (close the tab / clear the @@ -134,7 +136,7 @@ function buildReplyContext(email: Email): ProReplyContext { * Hosted by `ProEmailTabBody` (a dedicated email tab) and by the folder tab's * reading pane. */ -export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onOpenCompose, liveState, onMailChanged, onClose, className }: ProEmailViewProps) { +export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onOpenCompose, liveState, onMailChanged, noAutomaticActions, onClose, className }: ProEmailViewProps) { const t = useTranslations(); const tNotifications = useTranslations('notifications'); const tRemove = useTranslations('email_list.confirm_remove'); @@ -574,6 +576,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
( const nativeMode = isOpen && mounted && !nativeRefused && nativeMenusAvailable(); const positionRef = useRef(position); positionRef.current = position; + const tDesktop = useTranslations("desktopUi.menu"); + const tDesktopRef = useRef(tDesktop); + tDesktopRef.current = tDesktop; useEffect(() => { setMounted(true); @@ -280,8 +285,15 @@ export const ContextMenu = forwardRef( const place = at ? { x: at.align === "end" ? Math.max(0, at.right - root.getBoundingClientRect().width) : at.left, y: at.bottom + ANCHOR_GAP } : undefined; - await showNativeMenu(read, place); - if (!cancelled) onCloseRef.current(); + const outcome = await showNativeMenu(read, root, place, tDesktopRef.current("from_plugins")); + if (cancelled) return; + // "More from plugins": the menu itself, with what only it can show. + if (outcome === "html") { + setNativeRefused(true); + return; + } + if (outcome === "gone") toast.warning(tDesktopRef.current("pick_gone")); + onCloseRef.current(); })(); return () => { cancelled = true; diff --git a/lib/__tests__/outgoing-hosts-mode.test.ts b/lib/__tests__/outgoing-hosts-mode.test.ts index fd4b4d713..182cb26e1 100644 --- a/lib/__tests__/outgoing-hosts-mode.test.ts +++ b/lib/__tests__/outgoing-hosts-mode.test.ts @@ -82,6 +82,10 @@ const probes: Record Promise> = { await GET(new NextRequest('https://webmail.example/api/bimi?domain=probe.org')); return bimiResolve.mock.calls.length > 0; }, + 'spellcheck-dictionaries': async () => { + const { dictionaryDownloadAllowed } = await import('@/lib/desktop/dictionary-download'); + return dictionaryDownloadAllowed(); + }, translate: async () => { const { POST } = await import('@/app/api/translate/route'); await POST(new NextRequest('https://webmail.example/api/translate', { diff --git a/lib/admin/outgoing-hosts-registry.ts b/lib/admin/outgoing-hosts-registry.ts index dae391d33..dc585707f 100644 --- a/lib/admin/outgoing-hosts-registry.ts +++ b/lib/admin/outgoing-hosts-registry.ts @@ -106,6 +106,16 @@ export const OUTGOING_HOSTS: readonly OutgoingHost[] = [ inMode: 'Avatars show no brand logos; /api/bimi answers 403 before any lookup.', lite: false, }, + { + id: 'spellcheck-dictionaries', + host: 'redirector.gvt1.com', + files: ['lib/desktop/dictionary-download.ts'], + side: 'browser', + usedBy: 'Desktop app only: a spellcheck dictionary from Google, for a language the OS has no checker for, after the user said yes when the composer asked. The text being written is not sent.', + off: 'Answer no when the composer asks once. Nothing is fetched until a yes.', + inMode: 'The composer does not ask, and the app has no address to fetch dictionaries from.', + lite: false, + }, { id: 'translate', host: 'api.mymemory.translated.net', diff --git a/lib/compose-flush-registry.ts b/lib/compose-flush-registry.ts index b0215caab..ffa595310 100644 --- a/lib/compose-flush-registry.ts +++ b/lib/compose-flush-registry.ts @@ -14,6 +14,21 @@ import { getDesktopBridge } from '@/lib/desktop/bridge'; export type ComposeFlush = () => Promise; +const openedListeners = new Set<() => void>(); + +/** A composer is open in this window. */ +export function composerIsOpen(): boolean { + return flushes.size > 0; +} + +/** Calls `listener` as a composer opens. Returns the unsubscribe. */ +export function onComposerOpened(listener: () => void): () => void { + openedListeners.add(listener); + return () => { + openedListeners.delete(listener); + }; +} + /** Less than the desktop shell waits for a closing window. */ const DESKTOP_FLUSH_MS = 3000; @@ -44,6 +59,7 @@ export function registerComposeFlush(flush: ComposeFlush): () => void { flushes.set(id, flush); for (const waiter of [...waiters]) waiter(flush); syncDesktopFlush(); + for (const listener of [...openedListeners]) listener(); return () => { flushes.delete(id); syncDesktopFlush(); diff --git a/lib/desktop/__tests__/native-ui.test.tsx b/lib/desktop/__tests__/native-ui.test.tsx index 2bab20eb1..58df7bba2 100644 --- a/lib/desktop/__tests__/native-ui.test.tsx +++ b/lib/desktop/__tests__/native-ui.test.tsx @@ -8,6 +8,7 @@ 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'; +import { toast } from '@/stores/toast-store'; function setBridge(bridge: Partial | undefined) { window.bulwarkDesktop = bridge as BulwarkDesktopBridge | undefined; @@ -124,6 +125,42 @@ describe('an HTML menu shown natively', () => { expect(screen.getByRole('menuitem', { name: /Reply/ })).toBeTruthy(); }); + it('a plugin\'s frame keeps the rest native, with a line to the HTML menu', async () => { + const frame = ( +
'; + expect(readMenu(root).unsupported).toBe(true); + }); + + it('a row that went while the menu was up says so, instead of doing nothing', async () => { + const warning = vi.spyOn(toast, 'warning').mockImplementation(() => '' as never); + const reply = vi.fn(); + const { items, onClose } = await openMenu(testMenu(undefined, { reply })); + const row = screen.getAllByRole('menuitem', { hidden: true }).find((el) => el.textContent?.includes('Reply'))!; + row.parentElement!.remove(); + await act(async () => { + answer(items[1].id!); + }); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(reply).not.toHaveBeenCalled(); + expect(warning).toHaveBeenCalledWith('pick_gone'); + warning.mockRestore(); + }); + 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 = ''; @@ -242,7 +279,7 @@ describe('a pop-out closing itself', () => { describe('window settings', () => { it('both default to on, and stay on this computer', () => { - expect(readDesktopPrefs()).toEqual({ openOnDoubleClick: true, restoreWindows: true }); + expect(readDesktopPrefs()).toEqual({ openOnDoubleClick: true, restoreWindows: true, dictionaryDownload: 'ask' }); setDesktopPref('restoreWindows', false); resetDesktopPrefsCache(); expect(readDesktopPrefs().restoreWindows).toBe(false); diff --git a/lib/desktop/dictionary-download.ts b/lib/desktop/dictionary-download.ts new file mode 100644 index 000000000..587b7af39 --- /dev/null +++ b/lib/desktop/dictionary-download.ts @@ -0,0 +1,14 @@ +/** + * Spellcheck dictionaries for languages the OS has no checker for. They'd come from + * this Google host, so nothing is fetched until the user says yes in the composer, + * and never in no-third-party mode. The shell starts every session with the address + * pointing nowhere (src/main/spellcheck.ts in the desktop repo). + */ + +import { noThirdPartyTraffic } from '@/lib/third-party-mode'; + +export const DICTIONARY_HOST_URL = 'https://redirector.gvt1.com/edgedl/chrome/dict/'; + +export function dictionaryDownloadAllowed(): boolean { + return !noThirdPartyTraffic(); +} diff --git a/lib/desktop/languages.ts b/lib/desktop/languages.ts new file mode 100644 index 000000000..5c054dc28 --- /dev/null +++ b/lib/desktop/languages.ts @@ -0,0 +1,128 @@ +/** + * What the shell's menus and spellcheck should speak. The interface language and + * its translations of the menus come from the host, and each open composer adds the + * language its text is in, so spelling is checked in what's being written. + */ + +import { getDesktopBridge } from './bridge'; + +let ui = ''; +let labels: Record | undefined; +const composers = new Map(); + +function push(): void { + const shell = getDesktopBridge()?.shell; + if (!shell?.setLanguages || !ui) return; + shell.setLanguages({ ui, composer: [...new Set(composers.values())], labels }); +} + +export function setDesktopInterfaceLanguage(language: string, translated: Record): void { + ui = language; + labels = translated; + push(); +} + +/** With null, this composer has gone. */ +export function noteComposerLanguage(key: string, language: string | null): void { + if (language) { + if (composers.get(key) === language) return; + composers.set(key, language); + } else if (!composers.delete(key)) { + return; + } + push(); +} + +/** The shell's text in the interface language, by its keys in src/main/strings.ts. */ +export function shellLabels(t: (key: string) => string): Record { + return { + menuFile: t('desktopUi.menu.file'), + menuEdit: t('desktopUi.menu.edit'), + menuView: t('desktopUi.menu.view'), + menuMessage: t('desktopUi.menu.message'), + menuGo: t('desktopUi.menu.go'), + menuWindow: t('desktopUi.menu.window'), + menuHelp: t('desktopUi.menu.help'), + menuNewMessage: t('email_composer.new_message'), + menuNewWindow: t('desktopUi.menu.new_window'), + menuPrint: t('desktopUi.menu.print'), + menuSavePdf: t('desktopUi.menu.save_pdf'), + menuSwitchMode: t('desktopUi.menu.switch_connection'), + menuClose: t('desktopUi.menu.close'), + menuQuit: t('desktopUi.menu.quit'), + menuUndo: t('desktopUi.menu.undo'), + menuRedo: t('desktopUi.menu.redo'), + menuCut: t('desktopUi.menu.cut'), + menuCopy: t('desktopUi.menu.copy'), + menuPaste: t('desktopUi.menu.paste'), + menuSelectAll: t('desktopUi.menu.select_all'), + menuSearch: t('desktopUi.menu.search'), + menuRefresh: t('desktopUi.menu.refresh'), + menuReadingPane: t('desktopUi.menu.reading_pane'), + menuPaneRight: t('desktopUi.menu.pane_right'), + menuPaneBelow: t('desktopUi.menu.pane_below'), + menuPaneOff: t('desktopUi.menu.pane_off'), + menuZoomIn: t('desktopUi.menu.zoom_in'), + menuZoomOut: t('desktopUi.menu.zoom_out'), + menuZoomReset: t('desktopUi.menu.zoom_reset'), + menuFullScreen: t('desktopUi.menu.full_screen'), + menuReply: t('context_menu.reply'), + menuReplyAll: t('context_menu.reply_all'), + menuForward: t('context_menu.forward'), + menuMarkRead: t('context_menu.mark_read'), + menuMarkUnread: t('context_menu.mark_unread'), + menuStar: t('context_menu.star'), + menuMove: t('context_menu.move_to_dialog'), + menuArchive: t('context_menu.archive'), + menuSpam: t('context_menu.mark_as_spam'), + menuDelete: t('context_menu.delete'), + menuOpenInWindow: t('desktopUi.menu.open_in_window'), + menuGoInbox: t('sidebar.mailboxes.inbox'), + menuGoSent: t('sidebar.mailboxes.sent'), + menuGoDrafts: t('sidebar.mailboxes.drafts'), + menuGoNext: t('desktopUi.menu.next_message'), + menuGoPrevious: t('desktopUi.menu.previous_message'), + menuGoNextUnread: t('desktopUi.menu.next_unread'), + menuGoMail: t('desktopUi.menu.mail'), + menuGoCalendar: t('sidebar.calendar'), + menuGoContacts: t('sidebar.contacts'), + menuGoFiles: t('desktopUi.menu.files'), + menuGoBack: t('desktopUi.menu.back'), + menuGoForward: t('desktopUi.menu.forward'), + menuMinimize: t('desktopUi.menu.minimize'), + menuCycle: t('desktopUi.menu.next_window'), + menuShortcuts: t('desktopUi.menu.shortcuts'), + menuDocs: t('desktopUi.menu.docs'), + menuSettings: t('desktopUi.menu.settings'), + menuAbout: t('desktopUi.menu.about'), + contextCut: t('desktopUi.menu.cut'), + contextCopy: t('desktopUi.menu.copy'), + contextPaste: t('desktopUi.menu.paste'), + contextSelectAll: t('desktopUi.menu.select_all'), + contextAddToDictionary: t('desktopUi.context.add_to_dictionary'), + contextNoSuggestions: t('desktopUi.context.no_suggestions'), + contextOpenLink: t('desktopUi.context.open_link'), + contextCopyLink: t('desktopUi.context.copy_link'), + contextCopyAddress: t('desktopUi.context.copy_address'), + contextSaveImage: t('desktopUi.context.save_image'), + contextCopyImage: t('desktopUi.context.copy_image'), + unsavedTitle: t('desktopUi.closing.unsaved_title'), + unsavedDetail: t('desktopUi.closing.unsaved_detail'), + closeAnyway: t('desktopUi.closing.close_anyway'), + unsentTitle: t('desktopUi.closing.unsent_title'), + unsentOne: t('desktopUi.closing.unsent_one'), + unsentMany: t('desktopUi.closing.unsent_many'), + closeNow: t('desktopUi.closing.quit'), + leaveTitle: t('desktopUi.closing.leave_title'), + leaveDetail: t('desktopUi.closing.leave_detail'), + leave: t('desktopUi.closing.leave'), + stay: t('desktopUi.closing.stay'), + }; +} + +/** Test helper. */ +export function resetDesktopLanguages(): void { + ui = ''; + labels = undefined; + composers.clear(); +} diff --git a/lib/desktop/native-menu.ts b/lib/desktop/native-menu.ts index ae8eca8a5..5fac69c9e 100644 --- a/lib/desktop/native-menu.ts +++ b/lib/desktop/native-menu.ts @@ -4,8 +4,9 @@ * 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". + * A menu with something a native one can't show (a colour picker, a row with no text) + * stays HTML. So does anything marked data-native-menu="html". A plugin's frame in the + * menu doesn't: the rest shows natively, with one line that opens the HTML menu. */ import { getDesktopBridge } from './bridge'; @@ -21,10 +22,15 @@ export interface ReadMenu { elements: Map; /** There's something here a native menu can't show. */ unsupported: boolean; + /** A plugin drew something of its own in the menu. */ + hasFrames: boolean; } const ROW = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], button, a[href]'; +/** The native line that opens the HTML menu instead. */ +export const HTML_MENU_ITEM = "html-menu"; + function isDisabled(el: HTMLElement): boolean { return (el as HTMLButtonElement).disabled === true || el.getAttribute('aria-disabled') === 'true'; } @@ -40,7 +46,8 @@ function isHidden(el: Element): boolean { 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 unsupported = root.querySelector('[data-native-menu="html"], select, textarea, input[type="color"]') !== null; + let hasFrames = false; let next = 1; const walk = (container: Element, into: DesktopNativeMenuItem[]): void => { @@ -50,6 +57,12 @@ export function readMenu(root: HTMLElement): ReadMenu { into.push({ type: 'separator' }); continue; } + if (child.tagName === 'IFRAME') { + // Only a plugin's own slot has this coming. Anything else stays HTML whole. + if (child.closest('[data-plugin-slot]')) hasFrames = true; + else unsupported = true; + continue; + } if (child.dataset.submenuPanel !== undefined) continue; if (child.dataset.contextMenuHeader !== undefined) { const label = labelOf(child); @@ -105,7 +118,7 @@ export function readMenu(root: HTMLElement): ReadMenu { const items: DesktopNativeMenuItem[] = []; walk(root, items); - return { items, elements, unsupported }; + return { items, elements, unsupported, hasFrames }; } const SETTLE_MS = 30; @@ -127,17 +140,46 @@ export async function readSettledMenu(root: HTMLElement, cancelled: () => boolea 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 { +function labelOfId(items: DesktopNativeMenuItem[], id: string): string | undefined { + for (const item of items) { + if (item.id === id) return item.label; + const below = item.submenu && labelOfId(item.submenu, id); + if (below) return below; + } + return undefined; +} + +export type NativeMenuOutcome = 'done' | 'html' | 'gone'; + +/** + * Shows it and clicks the row picked. Says when the pick found nothing: the page can + * re-render under a menu that stays up (the list refreshing), and a row that's gone + * would otherwise do nothing with no word about it. If the same line is back at the + * same place, that's the one. + */ +export async function showNativeMenu(read: ReadMenu, root: HTMLElement, at?: { x: number; y: number }, fromPlugins?: string): Promise { const popup = getDesktopBridge()?.menu?.popup; - if (!popup) return; + if (!popup) return 'done'; + const items = read.hasFrames && fromPlugins + ? [...read.items, { type: 'separator' as const }, { id: HTML_MENU_ITEM, label: fromPlugins }] + : read.items; document.documentElement.dataset.nativeMenuOpen = ''; + let picked: string | null; try { - const picked = await popup(read.items, at); - if (picked) read.elements.get(picked)?.click(); + picked = await popup(items, at); } finally { delete document.documentElement.dataset.nativeMenuOpen; } + if (!picked) return 'done'; + if (picked === HTML_MENU_ITEM) return 'html'; + let row = read.elements.get(picked); + if (!row?.isConnected) { + const again = root.isConnected ? readMenu(root) : null; + row = again && labelOfId(again.items, picked) === labelOfId(read.items, picked) ? again.elements.get(picked) : undefined; + } + if (!row?.isConnected) return 'gone'; + row.click(); + return 'done'; } /** A native menu is up: the window's blur or a stray event says nothing about it. */ diff --git a/lib/desktop/open-link.ts b/lib/desktop/open-link.ts new file mode 100644 index 000000000..96c9c8f6f --- /dev/null +++ b/lib/desktop/open-link.ts @@ -0,0 +1,22 @@ +/** + * "Open Link" in the desktop app's native menu. It has to go where a click on the + * link goes: a mail address to the app's composer, anything else past + * resolveExternalLink, where a plugin can stop it or send it elsewhere. + */ + +import { isOpenableLinkHref } from '@/lib/email-sanitization'; +import { resolveExternalLink } from '@/lib/plugin-hook-result'; +import { requestInternalMailto } from '@/lib/protocol-handlers/mailto'; +import { getDesktopBridge } from './bridge'; + +export async function openLinkFromNativeMenu(href: string, text: string, emailId?: string): Promise { + const shell = getDesktopBridge()?.shell; + if (!shell) return; + if (/^mailto:/i.test(href)) { + if (!requestInternalMailto(href)) shell.openExternal(href); + return; + } + if (!isOpenableLinkHref(href)) return; + const target = await resolveExternalLink({ href, target: '_blank', emailId, text }); + if (target) shell.openExternal(target); +} diff --git a/lib/desktop/preferences.ts b/lib/desktop/preferences.ts index f9894c5c8..7cd499858 100644 --- a/lib/desktop/preferences.ts +++ b/lib/desktop/preferences.ts @@ -13,9 +13,11 @@ export interface DesktopWindowPrefs { openOnDoubleClick: boolean; /** The windows open at quit come back at the next start. */ restoreWindows: boolean; + /** Spellcheck dictionaries from Google, for a language the OS has no checker for. Asked once. */ + dictionaryDownload: 'ask' | 'yes' | 'no'; } -const DEFAULTS: DesktopWindowPrefs = { openOnDoubleClick: true, restoreWindows: true }; +const DEFAULTS: DesktopWindowPrefs = { openOnDoubleClick: true, restoreWindows: true, dictionaryDownload: 'ask' }; let cached: DesktopWindowPrefs | null = null; const listeners = new Set<() => void>(); @@ -32,6 +34,7 @@ export function readDesktopPrefs(): DesktopWindowPrefs { cached = { openOnDoubleClick: typeof stored.openOnDoubleClick === 'boolean' ? stored.openOnDoubleClick : DEFAULTS.openOnDoubleClick, restoreWindows: typeof stored.restoreWindows === 'boolean' ? stored.restoreWindows : DEFAULTS.restoreWindows, + dictionaryDownload: stored.dictionaryDownload === 'yes' || stored.dictionaryDownload === 'no' ? stored.dictionaryDownload : DEFAULTS.dictionaryDownload, }; return cached; } diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index f9eb6109b..485a95ff0 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -126,8 +126,16 @@ export interface DesktopMenuBridge { 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; + /** The shell's menus follow `ui`, in `labels` (lib/desktop/languages.ts). Spellcheck follows `ui` and `composer`. */ + setLanguages?: (languages: { ui: string; composer?: string[]; labels?: Record }) => void; +} + +export interface DesktopSpellcheckBridge { + /** Languages with neither an OS checker nor a dictionary here. */ + missing: () => Promise; + onMissing: (listener: (language: string) => void) => () => void; + /** The user said yes: dictionaries may come from Google. Never with no-third-party mode on. */ + allowDownload: () => void; } export interface DesktopDialogsBridge { @@ -144,6 +152,8 @@ export interface DesktopDialogsBridge { 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; + /** Signed out: nothing copied from that account stays on disk. */ + clearFiles?: () => void; } export type DesktopServerPrepareResult = @@ -281,6 +291,7 @@ export interface BulwarkDesktopBridge { menu?: DesktopMenuBridge; shell?: DesktopShellBridge; dialogs?: DesktopDialogsBridge; + spellcheck?: DesktopSpellcheckBridge; clipboard?: DesktopClipboardBridge; lifecycle?: DesktopLifecycleBridge; servers?: DesktopServersBridge; diff --git a/lib/draft-composer-data.ts b/lib/draft-composer-data.ts index 82eebc52d..8459d2e8d 100644 --- a/lib/draft-composer-data.ts +++ b/lib/draft-composer-data.ts @@ -1,10 +1,25 @@ 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 type { Email, Identity, Mailbox } from '@/lib/jmap/types'; import { messagePriorityFromHeaders } from '@/lib/message-priority'; import { reopenedLabelChoice } from '@/lib/outgoing/classification'; +/** + * Whether a message may reopen as a draft from an id kept since last time. Sending + * keeps the id, and a composer on it would replace, resend or delete the message. A + * draft has `$draft` or sits in Drafts (a send undone comes back without the keyword); + * and in Sent or Trash it's nothing to edit, whatever its keywords say. + */ +export function isRestorableDraft( + draft: Pick, + mailboxes: Pick[], +): boolean { + const roles = Object.keys(draft.mailboxIds ?? {}).map((id) => mailboxes.find((m) => m.id === id || m.originalId === id)?.role); + if (roles.includes('sent') || roles.includes('trash')) return false; + return !!draft.keywords?.$draft || roles.includes('drafts'); +} + /** * 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. diff --git a/lib/outgoing-hosts.ts b/lib/outgoing-hosts.ts index c58c8608b..91f3f4dc7 100644 --- a/lib/outgoing-hosts.ts +++ b/lib/outgoing-hosts.ts @@ -21,6 +21,7 @@ export const BUILT_IN_HOSTNAMES: readonly string[] = [ 'www.google.com', 'randomuser.me', 'img.freepik.com', + 'redirector.gvt1.com', ]; function hostnameOf(url: string): string | null { diff --git a/locales/ar/common.json b/locales/ar/common.json index b19d3aeeb..f0b57fb42 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6558,6 +6558,85 @@ "save_as": "حفظ باسم…", "copy": "نسخ", "copy_failed": "تعذّر نسخ الملف." + }, + "menu": { + "file": "ملف", + "edit": "تحرير", + "view": "عرض", + "message": "رسالة", + "go": "انتقال", + "window": "نافذة", + "help": "مساعدة", + "new_window": "نافذة جديدة", + "print": "طباعة…", + "save_pdf": "حفظ بصيغة PDF…", + "switch_connection": "تغيير الاتصال…", + "close": "إغلاق", + "quit": "إنهاء", + "undo": "تراجع", + "redo": "إعادة", + "cut": "قص", + "copy": "نسخ", + "paste": "لصق", + "select_all": "تحديد الكل", + "search": "بحث في البريد", + "refresh": "التحقق من البريد الجديد", + "reading_pane": "جزء القراءة", + "pane_right": "على اليمين", + "pane_below": "أسفل القائمة", + "pane_off": "إيقاف", + "zoom_in": "تكبير", + "zoom_out": "تصغير", + "zoom_reset": "الحجم الفعلي", + "full_screen": "ملء الشاشة", + "open_in_window": "فتح في نافذة جديدة", + "next_message": "الرسالة التالية", + "previous_message": "الرسالة السابقة", + "next_unread": "الرسالة التالية غير المقروءة", + "mail": "البريد", + "files": "الملفات", + "back": "رجوع", + "forward": "للأمام", + "minimize": "تصغير", + "next_window": "النافذة التالية", + "shortcuts": "اختصارات لوحة المفاتيح", + "docs": "الوثائق", + "settings": "الإعدادات…", + "about": "حول Bulwark", + "from_plugins": "المزيد من الإضافات…", + "pick_gone": "هذه القائمة قديمة. افتحها مرة أخرى وحاول ثانية." + }, + "context": { + "add_to_dictionary": "إضافة «{word}» إلى القاموس", + "no_suggestions": "لا توجد اقتراحات", + "open_link": "فتح الرابط", + "copy_link": "نسخ الرابط", + "copy_address": "نسخ العنوان", + "save_image": "حفظ الصورة…", + "copy_image": "نسخ الصورة" + }, + "closing": { + "unsaved_title": "تعذّر حفظ مسودة", + "unsaved_detail": "إذا أغلقت الآن، سيضيع ما كتبته منذ آخر حفظ.", + "close_anyway": "إغلاق على أي حال", + "unsent_title": "توجد رسائل لم تُرسل بعد", + "unsent_one": "توجد رسالة واحدة في انتظار الإرسال. ستُرسل في المرة القادمة التي تفتح فيها Bulwark.", + "unsent_many": "توجد {count} رسائل في انتظار الإرسال. ستُرسل في المرة القادمة التي تفتح فيها Bulwark.", + "quit": "إنهاء", + "leave_title": "المغادرة مع تغييرات غير محفوظة؟", + "leave_detail": "لم تُحفظ بعد التغييرات التي أجريتها في هذه الصفحة.", + "leave": "مغادرة", + "stay": "بقاء" + }, + "spellcheck": { + "offer": "يحتاج التدقيق الإملائي بلغة {language} إلى قاموس. يمكن لـ Bulwark تنزيله من Google. لا يُرسل نصك.", + "download": "تنزيل" + }, + "restore": { + "unavailable_title": "تعذّر فتح هذه المسودة الآن", + "unavailable_body": "إنها محفوظة بأمان على الخادم في المسودات. حاول مرة أخرى عند عودة الاتصال.", + "try_again": "إعادة المحاولة", + "close": "إغلاق" } }, "desktop_sign_in": { diff --git a/locales/ca/common.json b/locales/ca/common.json index b4c960e21..556caead6 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6558,6 +6558,85 @@ "save_as": "Desa com a…", "copy": "Copia", "copy_failed": "No s’ha pogut copiar el fitxer." + }, + "menu": { + "file": "Fitxer", + "edit": "Edita", + "view": "Visualitza", + "message": "Missatge", + "go": "Vés-hi", + "window": "Finestra", + "help": "Ajuda", + "new_window": "Finestra nova", + "print": "Imprimeix…", + "save_pdf": "Desa com a PDF…", + "switch_connection": "Canvia de connexió…", + "close": "Tanca", + "quit": "Surt", + "undo": "Desfés", + "redo": "Refés", + "cut": "Retalla", + "copy": "Copia", + "paste": "Enganxa", + "select_all": "Selecciona-ho tot", + "search": "Cerca al correu", + "refresh": "Comprova si hi ha correu nou", + "reading_pane": "Tauler de lectura", + "pane_right": "A la dreta", + "pane_below": "Sota la llista", + "pane_off": "Desactivat", + "zoom_in": "Amplia", + "zoom_out": "Redueix", + "zoom_reset": "Mida real", + "full_screen": "Pantalla completa", + "open_in_window": "Obre en una finestra nova", + "next_message": "Missatge següent", + "previous_message": "Missatge anterior", + "next_unread": "Següent missatge no llegit", + "mail": "Correu", + "files": "Fitxers", + "back": "Enrere", + "forward": "Endavant", + "minimize": "Minimitza", + "next_window": "Finestra següent", + "shortcuts": "Dreceres de teclat", + "docs": "Documentació", + "settings": "Configuració…", + "about": "Quant a Bulwark", + "from_plugins": "Més dels connectors…", + "pick_gone": "Aquest menú estava desfasat. Torneu-lo a obrir i proveu-ho una altra vegada." + }, + "context": { + "add_to_dictionary": "Afegeix «{word}» al diccionari", + "no_suggestions": "Cap suggeriment", + "open_link": "Obre l’enllaç", + "copy_link": "Copia l’enllaç", + "copy_address": "Copia l’adreça", + "save_image": "Desa la imatge…", + "copy_image": "Copia la imatge" + }, + "closing": { + "unsaved_title": "No s’ha pogut desar un esborrany", + "unsaved_detail": "Si tanqueu ara, es perdrà el que heu escrit des de l’últim desament.", + "close_anyway": "Tanca igualment", + "unsent_title": "Hi ha correu sense enviar", + "unsent_one": "Hi ha un missatge pendent d’enviar. Se n’anirà la propera vegada que obriu Bulwark.", + "unsent_many": "Hi ha {count} missatges pendents d’enviar. Se n’aniran la propera vegada que obriu Bulwark.", + "quit": "Surt", + "leave_title": "Voleu sortir amb canvis sense desar?", + "leave_detail": "Els canvis fets en aquesta pàgina encara no s’han desat.", + "leave": "Surt", + "stay": "Queda’t" + }, + "spellcheck": { + "offer": "La revisió ortogràfica en {language} necessita un diccionari. Bulwark el pot baixar de Google. El vostre text no s’envia.", + "download": "Baixa" + }, + "restore": { + "unavailable_title": "Aquest esborrany no es pot obrir ara", + "unavailable_body": "Està segur al servidor, a Esborranys. Torneu-ho a provar quan hi hagi connexió.", + "try_again": "Torna-ho a provar", + "close": "Tanca" } }, "desktop_sign_in": { diff --git a/locales/cs/common.json b/locales/cs/common.json index 28c8b0968..170d6ce85 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6558,6 +6558,85 @@ "save_as": "Uložit jako…", "copy": "Kopírovat", "copy_failed": "Soubor se nepodařilo zkopírovat." + }, + "menu": { + "file": "Soubor", + "edit": "Úpravy", + "view": "Zobrazení", + "message": "Zpráva", + "go": "Přejít", + "window": "Okno", + "help": "Nápověda", + "new_window": "Nové okno", + "print": "Tisk…", + "save_pdf": "Uložit jako PDF…", + "switch_connection": "Změnit připojení…", + "close": "Zavřít", + "quit": "Ukončit", + "undo": "Zpět", + "redo": "Znovu", + "cut": "Vyjmout", + "copy": "Kopírovat", + "paste": "Vložit", + "select_all": "Vybrat vše", + "search": "Hledat v poště", + "refresh": "Zkontrolovat novou poštu", + "reading_pane": "Podokno čtení", + "pane_right": "Vpravo", + "pane_below": "Pod seznamem", + "pane_off": "Vypnuto", + "zoom_in": "Přiblížit", + "zoom_out": "Oddálit", + "zoom_reset": "Skutečná velikost", + "full_screen": "Celá obrazovka", + "open_in_window": "Otevřít v novém okně", + "next_message": "Další zpráva", + "previous_message": "Předchozí zpráva", + "next_unread": "Další nepřečtená zpráva", + "mail": "Pošta", + "files": "Soubory", + "back": "Zpět", + "forward": "Vpřed", + "minimize": "Minimalizovat", + "next_window": "Další okno", + "shortcuts": "Klávesové zkratky", + "docs": "Dokumentace", + "settings": "Nastavení…", + "about": "O aplikaci Bulwark", + "from_plugins": "Více z doplňků…", + "pick_gone": "Tato nabídka byla zastaralá. Otevřete ji znovu a zkuste to ještě jednou." + }, + "context": { + "add_to_dictionary": "Přidat „{word}“ do slovníku", + "no_suggestions": "Žádné návrhy", + "open_link": "Otevřít odkaz", + "copy_link": "Kopírovat odkaz", + "copy_address": "Kopírovat adresu", + "save_image": "Uložit obrázek…", + "copy_image": "Kopírovat obrázek" + }, + "closing": { + "unsaved_title": "Koncept se nepodařilo uložit", + "unsaved_detail": "Pokud nyní okno zavřete, přijdete o to, co jste napsali od posledního uložení.", + "close_anyway": "Přesto zavřít", + "unsent_title": "Některá pošta ještě nebyla odeslána", + "unsent_one": "Jedna zpráva čeká na odeslání. Odejde při dalším spuštění Bulwark.", + "unsent_many": "Počet zpráv čekajících na odeslání: {count}. Odejdou při dalším spuštění Bulwark.", + "quit": "Ukončit", + "leave_title": "Odejít s neuloženými změnami?", + "leave_detail": "Změny na této stránce zatím nejsou uloženy.", + "leave": "Odejít", + "stay": "Zůstat" + }, + "spellcheck": { + "offer": "Kontrola pravopisu pro jazyk {language} potřebuje slovník. Bulwark ho může stáhnout od společnosti Google. Váš text se neodesílá.", + "download": "Stáhnout" + }, + "restore": { + "unavailable_title": "Tento koncept se teď nedá otevřít", + "unavailable_body": "Je v bezpečí na serveru ve složce Koncepty. Zkuste to znovu, až se obnoví připojení.", + "try_again": "Zkusit znovu", + "close": "Zavřít" } }, "desktop_sign_in": { diff --git a/locales/da/common.json b/locales/da/common.json index e47034d0e..0122e60fe 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6558,6 +6558,85 @@ "save_as": "Gem som…", "copy": "Kopiér", "copy_failed": "Filen kunne ikke kopieres." + }, + "menu": { + "file": "Filer", + "edit": "Rediger", + "view": "Vis", + "message": "Besked", + "go": "Gå", + "window": "Vindue", + "help": "Hjælp", + "new_window": "Nyt vindue", + "print": "Udskriv…", + "save_pdf": "Gem som PDF…", + "switch_connection": "Skift forbindelse…", + "close": "Luk", + "quit": "Afslut", + "undo": "Fortryd", + "redo": "Gentag", + "cut": "Klip", + "copy": "Kopiér", + "paste": "Indsæt", + "select_all": "Markér alt", + "search": "Søg i mail", + "refresh": "Hent nye mails", + "reading_pane": "Læserude", + "pane_right": "Til højre", + "pane_below": "Under listen", + "pane_off": "Fra", + "zoom_in": "Zoom ind", + "zoom_out": "Zoom ud", + "zoom_reset": "Faktisk størrelse", + "full_screen": "Fuld skærm", + "open_in_window": "Åbn i nyt vindue", + "next_message": "Næste besked", + "previous_message": "Forrige besked", + "next_unread": "Næste ulæste besked", + "mail": "Mail", + "files": "Filer", + "back": "Tilbage", + "forward": "Frem", + "minimize": "Minimer", + "next_window": "Næste vindue", + "shortcuts": "Tastaturgenveje", + "docs": "Dokumentation", + "settings": "Indstillinger…", + "about": "Om Bulwark", + "from_plugins": "Mere fra plugins…", + "pick_gone": "Den menu var forældet. Åbn den igen, og prøv en gang til." + }, + "context": { + "add_to_dictionary": "Føj “{word}” til ordbogen", + "no_suggestions": "Ingen forslag", + "open_link": "Åbn link", + "copy_link": "Kopiér link", + "copy_address": "Kopiér adresse", + "save_image": "Gem billede…", + "copy_image": "Kopiér billede" + }, + "closing": { + "unsaved_title": "En kladde kunne ikke gemmes", + "unsaved_detail": "Hvis du lukker nu, går det tabt, du har skrevet siden sidste gang, du gemte.", + "close_anyway": "Luk alligevel", + "unsent_title": "Nogle mails er ikke sendt endnu", + "unsent_one": "Én besked venter på at blive sendt. Den bliver sendt, næste gang du åbner Bulwark.", + "unsent_many": "{count} beskeder venter på at blive sendt. De bliver sendt, næste gang du åbner Bulwark.", + "quit": "Afslut", + "leave_title": "Forlad siden med ugemte ændringer?", + "leave_detail": "Dine ændringer på denne side er ikke gemt endnu.", + "leave": "Forlad", + "stay": "Bliv" + }, + "spellcheck": { + "offer": "Stavekontrol på {language} kræver en ordbog. Bulwark kan hente den fra Google. Din tekst bliver ikke sendt.", + "download": "Hent" + }, + "restore": { + "unavailable_title": "Denne kladde kan ikke åbnes lige nu", + "unavailable_body": "Den er sikker på serveren i Kladder. Prøv igen, når forbindelsen er tilbage.", + "try_again": "Prøv igen", + "close": "Luk" } }, "desktop_sign_in": { diff --git a/locales/de/common.json b/locales/de/common.json index ca3134e5d..fa98785e4 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6558,6 +6558,85 @@ "save_as": "Speichern unter…", "copy": "Kopieren", "copy_failed": "Die Datei konnte nicht kopiert werden." + }, + "menu": { + "file": "Datei", + "edit": "Bearbeiten", + "view": "Ansicht", + "message": "Nachricht", + "go": "Gehe zu", + "window": "Fenster", + "help": "Hilfe", + "new_window": "Neues Fenster", + "print": "Drucken…", + "save_pdf": "Als PDF sichern…", + "switch_connection": "Verbindung wechseln…", + "close": "Schließen", + "quit": "Beenden", + "undo": "Rückgängig", + "redo": "Wiederholen", + "cut": "Ausschneiden", + "copy": "Kopieren", + "paste": "Einfügen", + "select_all": "Alles auswählen", + "search": "E-Mails durchsuchen", + "refresh": "Neue E-Mails abrufen", + "reading_pane": "Lesebereich", + "pane_right": "Rechts", + "pane_below": "Unter der Liste", + "pane_off": "Aus", + "zoom_in": "Vergrößern", + "zoom_out": "Verkleinern", + "zoom_reset": "Tatsächliche Größe", + "full_screen": "Vollbild", + "open_in_window": "In neuem Fenster öffnen", + "next_message": "Nächste Nachricht", + "previous_message": "Vorherige Nachricht", + "next_unread": "Nächste ungelesene Nachricht", + "mail": "E-Mail", + "files": "Dateien", + "back": "Zurück", + "forward": "Vorwärts", + "minimize": "Minimieren", + "next_window": "Nächstes Fenster", + "shortcuts": "Tastenkürzel", + "docs": "Dokumentation", + "settings": "Einstellungen…", + "about": "Über Bulwark", + "from_plugins": "Mehr von Plugins…", + "pick_gone": "Dieses Menü war veraltet. Öffnen Sie es erneut und versuchen Sie es noch einmal." + }, + "context": { + "add_to_dictionary": "„{word}“ zum Wörterbuch hinzufügen", + "no_suggestions": "Keine Vorschläge", + "open_link": "Link öffnen", + "copy_link": "Link kopieren", + "copy_address": "Adresse kopieren", + "save_image": "Bild sichern…", + "copy_image": "Bild kopieren" + }, + "closing": { + "unsaved_title": "Ein Entwurf konnte nicht gespeichert werden", + "unsaved_detail": "Wenn Sie jetzt schließen, geht verloren, was Sie seit dem letzten Speichern geschrieben haben.", + "close_anyway": "Trotzdem schließen", + "unsent_title": "Einige E-Mails wurden noch nicht gesendet", + "unsent_one": "Eine Nachricht wartet auf den Versand. Sie wird beim nächsten Start von Bulwark gesendet.", + "unsent_many": "{count} Nachrichten warten auf den Versand. Sie werden beim nächsten Start von Bulwark gesendet.", + "quit": "Beenden", + "leave_title": "Mit ungespeicherten Änderungen verlassen?", + "leave_detail": "Ihre Änderungen auf dieser Seite sind noch nicht gespeichert.", + "leave": "Verlassen", + "stay": "Bleiben" + }, + "spellcheck": { + "offer": "Die Rechtschreibprüfung für {language} benötigt ein Wörterbuch. Bulwark kann es von Google herunterladen. Ihr Text wird nicht gesendet.", + "download": "Herunterladen" + }, + "restore": { + "unavailable_title": "Dieser Entwurf kann gerade nicht geöffnet werden", + "unavailable_body": "Er ist auf dem Server im Ordner „Entwürfe“ gesichert. Versuchen Sie es erneut, sobald die Verbindung wieder besteht.", + "try_again": "Erneut versuchen", + "close": "Schließen" } }, "desktop_sign_in": { diff --git a/locales/en/common.json b/locales/en/common.json index e178107a0..6112c3ddd 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6558,6 +6558,85 @@ "save_as": "Save as…", "copy": "Copy", "copy_failed": "The file could not be copied." + }, + "menu": { + "file": "File", + "edit": "Edit", + "view": "View", + "message": "Message", + "go": "Go", + "window": "Window", + "help": "Help", + "new_window": "New Window", + "print": "Print…", + "save_pdf": "Save as PDF…", + "switch_connection": "Change Connection…", + "close": "Close", + "quit": "Quit", + "undo": "Undo", + "redo": "Redo", + "cut": "Cut", + "copy": "Copy", + "paste": "Paste", + "select_all": "Select All", + "search": "Search Mail", + "refresh": "Check for New Mail", + "reading_pane": "Reading Pane", + "pane_right": "On the Right", + "pane_below": "Below the List", + "pane_off": "Off", + "zoom_in": "Zoom In", + "zoom_out": "Zoom Out", + "zoom_reset": "Actual Size", + "full_screen": "Full Screen", + "open_in_window": "Open in New Window", + "next_message": "Next Message", + "previous_message": "Previous Message", + "next_unread": "Next Unread Message", + "mail": "Mail", + "files": "Files", + "back": "Back", + "forward": "Forward", + "minimize": "Minimize", + "next_window": "Next Window", + "shortcuts": "Keyboard Shortcuts", + "docs": "Documentation", + "settings": "Settings…", + "about": "About Bulwark", + "from_plugins": "More from plugins…", + "pick_gone": "That menu was out of date. Open it again and try once more." + }, + "context": { + "add_to_dictionary": "Add \"{word}\" to Dictionary", + "no_suggestions": "No Suggestions", + "open_link": "Open Link", + "copy_link": "Copy Link", + "copy_address": "Copy Address", + "save_image": "Save Image…", + "copy_image": "Copy Image" + }, + "closing": { + "unsaved_title": "A draft could not be saved", + "unsaved_detail": "If you close now, what you wrote since the last save is lost.", + "close_anyway": "Close anyway", + "unsent_title": "Some mail has not been sent yet", + "unsent_one": "One message is waiting to be sent. It goes out the next time you open Bulwark.", + "unsent_many": "{count} messages are waiting to be sent. They go out the next time you open Bulwark.", + "quit": "Quit", + "leave_title": "Leave with unsaved changes?", + "leave_detail": "What you changed on this page is not saved yet.", + "leave": "Leave", + "stay": "Stay" + }, + "spellcheck": { + "offer": "Spell checking in {language} needs a dictionary. Bulwark can download it from Google. Your text isn't sent.", + "download": "Download" + }, + "restore": { + "unavailable_title": "This draft can't be opened right now", + "unavailable_body": "It's safe on the server, in Drafts. Try again when the connection is back.", + "try_again": "Try again", + "close": "Close" } }, "desktop_sign_in": { diff --git a/locales/es/common.json b/locales/es/common.json index 92ae5cfc1..7b1a9627c 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6558,6 +6558,85 @@ "save_as": "Guardar como…", "copy": "Copiar", "copy_failed": "No se pudo copiar el archivo." + }, + "menu": { + "file": "Archivo", + "edit": "Edición", + "view": "Visualización", + "message": "Mensaje", + "go": "Ir", + "window": "Ventana", + "help": "Ayuda", + "new_window": "Nueva ventana", + "print": "Imprimir…", + "save_pdf": "Guardar como PDF…", + "switch_connection": "Cambiar de conexión…", + "close": "Cerrar", + "quit": "Salir", + "undo": "Deshacer", + "redo": "Rehacer", + "cut": "Cortar", + "copy": "Copiar", + "paste": "Pegar", + "select_all": "Seleccionar todo", + "search": "Buscar en el correo", + "refresh": "Comprobar correo nuevo", + "reading_pane": "Panel de lectura", + "pane_right": "A la derecha", + "pane_below": "Debajo de la lista", + "pane_off": "Desactivado", + "zoom_in": "Acercar", + "zoom_out": "Alejar", + "zoom_reset": "Tamaño real", + "full_screen": "Pantalla completa", + "open_in_window": "Abrir en una ventana nueva", + "next_message": "Mensaje siguiente", + "previous_message": "Mensaje anterior", + "next_unread": "Siguiente mensaje no leído", + "mail": "Correo", + "files": "Archivos", + "back": "Atrás", + "forward": "Adelante", + "minimize": "Minimizar", + "next_window": "Ventana siguiente", + "shortcuts": "Métodos abreviados de teclado", + "docs": "Documentación", + "settings": "Configuración…", + "about": "Acerca de Bulwark", + "from_plugins": "Más de los complementos…", + "pick_gone": "Ese menú estaba desactualizado. Ábralo de nuevo e inténtelo otra vez." + }, + "context": { + "add_to_dictionary": "Añadir «{word}» al diccionario", + "no_suggestions": "Sin sugerencias", + "open_link": "Abrir enlace", + "copy_link": "Copiar enlace", + "copy_address": "Copiar dirección", + "save_image": "Guardar imagen…", + "copy_image": "Copiar imagen" + }, + "closing": { + "unsaved_title": "No se pudo guardar un borrador", + "unsaved_detail": "Si cierra ahora, se perderá lo que escribió desde el último guardado.", + "close_anyway": "Cerrar de todos modos", + "unsent_title": "Hay correo sin enviar", + "unsent_one": "Hay un mensaje esperando para enviarse. Saldrá la próxima vez que abra Bulwark.", + "unsent_many": "Hay {count} mensajes esperando para enviarse. Saldrán la próxima vez que abra Bulwark.", + "quit": "Salir", + "leave_title": "¿Salir con cambios sin guardar?", + "leave_detail": "Los cambios que hizo en esta página aún no se han guardado.", + "leave": "Salir", + "stay": "Quedarse" + }, + "spellcheck": { + "offer": "La revisión ortográfica en {language} necesita un diccionario. Bulwark puede descargarlo de Google. Su texto no se envía.", + "download": "Descargar" + }, + "restore": { + "unavailable_title": "Este borrador no se puede abrir ahora", + "unavailable_body": "Está a salvo en el servidor, en Borradores. Inténtelo de nuevo cuando vuelva la conexión.", + "try_again": "Reintentar", + "close": "Cerrar" } }, "desktop_sign_in": { diff --git a/locales/et/common.json b/locales/et/common.json index af5abde97..5933106bb 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6558,6 +6558,85 @@ "save_as": "Salvesta nimega…", "copy": "Kopeeri", "copy_failed": "Faili ei saanud kopeerida." + }, + "menu": { + "file": "Fail", + "edit": "Redigeerimine", + "view": "Vaade", + "message": "Kiri", + "go": "Mine", + "window": "Aken", + "help": "Spikker", + "new_window": "Uus aken", + "print": "Prindi…", + "save_pdf": "Salvesta PDF-ina…", + "switch_connection": "Vaheta ühendust…", + "close": "Sulge", + "quit": "Välju", + "undo": "Võta tagasi", + "redo": "Tee uuesti", + "cut": "Lõika", + "copy": "Kopeeri", + "paste": "Kleebi", + "select_all": "Vali kõik", + "search": "Otsi kirjadest", + "refresh": "Kontrolli uusi kirju", + "reading_pane": "Lugemisvaade", + "pane_right": "Paremal", + "pane_below": "Loendi all", + "pane_off": "Väljas", + "zoom_in": "Suurenda", + "zoom_out": "Vähenda", + "zoom_reset": "Tegelik suurus", + "full_screen": "Täisekraan", + "open_in_window": "Ava uues aknas", + "next_message": "Järgmine kiri", + "previous_message": "Eelmine kiri", + "next_unread": "Järgmine lugemata kiri", + "mail": "Post", + "files": "Failid", + "back": "Tagasi", + "forward": "Edasi", + "minimize": "Minimeeri", + "next_window": "Järgmine aken", + "shortcuts": "Klaviatuuri otseteed", + "docs": "Dokumentatsioon", + "settings": "Seaded…", + "about": "Teave Bulwarki kohta", + "from_plugins": "Veel pistikprogrammidelt…", + "pick_gone": "See menüü oli aegunud. Avage see uuesti ja proovige veel kord." + }, + "context": { + "add_to_dictionary": "Lisa „{word}” sõnastikku", + "no_suggestions": "Soovitusi pole", + "open_link": "Ava link", + "copy_link": "Kopeeri link", + "copy_address": "Kopeeri aadress", + "save_image": "Salvesta pilt…", + "copy_image": "Kopeeri pilt" + }, + "closing": { + "unsaved_title": "Mustandit ei õnnestunud salvestada", + "unsaved_detail": "Kui sulgete nüüd, läheb kaduma see, mille olete pärast viimast salvestamist kirjutanud.", + "close_anyway": "Sulge siiski", + "unsent_title": "Mõni kiri on veel saatmata", + "unsent_one": "Üks kiri ootab saatmist. See läheb teele järgmine kord, kui Bulwarki avate.", + "unsent_many": "{count} kirja ootab saatmist. Need lähevad teele järgmine kord, kui Bulwarki avate.", + "quit": "Välju", + "leave_title": "Lahkuda salvestamata muudatustega?", + "leave_detail": "Selle lehe muudatused pole veel salvestatud.", + "leave": "Lahku", + "stay": "Jää" + }, + "spellcheck": { + "offer": "Õigekirjakontroll keelele {language} vajab sõnastikku. Bulwark saab selle Google’ilt alla laadida. Teie teksti ei saadeta.", + "download": "Laadi alla" + }, + "restore": { + "unavailable_title": "Seda mustandit ei saa praegu avada", + "unavailable_body": "See on serveris kaustas Mustandid turvaliselt alles. Proovige uuesti, kui ühendus on tagasi.", + "try_again": "Proovi uuesti", + "close": "Sulge" } }, "desktop_sign_in": { diff --git a/locales/fa/common.json b/locales/fa/common.json index b25da5a9b..e7ed42adf 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6558,6 +6558,85 @@ "save_as": "ذخیره با نام…", "copy": "کپی", "copy_failed": "فایل کپی نشد." + }, + "menu": { + "file": "پرونده", + "edit": "ویرایش", + "view": "نما", + "message": "پیام", + "go": "برو به", + "window": "پنجره", + "help": "راهنما", + "new_window": "پنجره جدید", + "print": "چاپ…", + "save_pdf": "ذخیره به‌صورت PDF…", + "switch_connection": "تغییر اتصال…", + "close": "بستن", + "quit": "خروج", + "undo": "واگرد", + "redo": "ازنو", + "cut": "برش", + "copy": "رونوشت", + "paste": "چسباندن", + "select_all": "انتخاب همه", + "search": "جستجو در نامه‌ها", + "refresh": "بررسی نامه‌های جدید", + "reading_pane": "قاب خواندن", + "pane_right": "سمت راست", + "pane_below": "زیر فهرست", + "pane_off": "خاموش", + "zoom_in": "بزرگ‌نمایی", + "zoom_out": "کوچک‌نمایی", + "zoom_reset": "اندازه واقعی", + "full_screen": "تمام‌صفحه", + "open_in_window": "باز کردن در پنجره جدید", + "next_message": "پیام بعدی", + "previous_message": "پیام قبلی", + "next_unread": "پیام خوانده‌نشده بعدی", + "mail": "نامه", + "files": "پرونده‌ها", + "back": "بازگشت", + "forward": "جلو", + "minimize": "کوچک کردن", + "next_window": "پنجره بعدی", + "shortcuts": "میان‌برهای صفحه‌کلید", + "docs": "مستندات", + "settings": "تنظیمات…", + "about": "درباره Bulwark", + "from_plugins": "موارد بیشتر از افزونه‌ها…", + "pick_gone": "این منو قدیمی شده بود. دوباره آن را باز کنید و یک بار دیگر امتحان کنید." + }, + "context": { + "add_to_dictionary": "افزودن «{word}» به واژه‌نامه", + "no_suggestions": "پیشنهادی نیست", + "open_link": "باز کردن پیوند", + "copy_link": "رونوشت پیوند", + "copy_address": "رونوشت نشانی", + "save_image": "ذخیره تصویر…", + "copy_image": "رونوشت تصویر" + }, + "closing": { + "unsaved_title": "یک پیش‌نویس ذخیره نشد", + "unsaved_detail": "اگر اکنون ببندید، آنچه از آخرین ذخیره نوشته‌اید از بین می‌رود.", + "close_anyway": "با این حال بسته شود", + "unsent_title": "برخی نامه‌ها هنوز ارسال نشده‌اند", + "unsent_one": "یک پیام در انتظار ارسال است. دفعه بعد که Bulwark را باز کنید ارسال می‌شود.", + "unsent_many": "{count} پیام در انتظار ارسال هستند. دفعه بعد که Bulwark را باز کنید ارسال می‌شوند.", + "quit": "خروج", + "leave_title": "با تغییرات ذخیره‌نشده خارج می‌شوید؟", + "leave_detail": "تغییراتی که در این صفحه داده‌اید هنوز ذخیره نشده است.", + "leave": "خروج", + "stay": "ماندن" + }, + "spellcheck": { + "offer": "بررسی املای {language} به واژه‌نامه نیاز دارد. Bulwark می‌تواند آن را از Google دانلود کند. متن شما ارسال نمی‌شود.", + "download": "دانلود" + }, + "restore": { + "unavailable_title": "اکنون نمی‌توان این پیش‌نویس را باز کرد", + "unavailable_body": "این پیش‌نویس در پوشه پیش‌نویس‌ها روی سرور سالم است. وقتی اتصال برگشت دوباره امتحان کنید.", + "try_again": "تلاش دوباره", + "close": "بستن" } }, "desktop_sign_in": { diff --git a/locales/fr/common.json b/locales/fr/common.json index a283bf2a2..db3f72230 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6558,6 +6558,85 @@ "save_as": "Enregistrer sous…", "copy": "Copier", "copy_failed": "Impossible de copier le fichier." + }, + "menu": { + "file": "Fichier", + "edit": "Édition", + "view": "Présentation", + "message": "Message", + "go": "Aller", + "window": "Fenêtre", + "help": "Aide", + "new_window": "Nouvelle fenêtre", + "print": "Imprimer…", + "save_pdf": "Enregistrer au format PDF…", + "switch_connection": "Changer de connexion…", + "close": "Fermer", + "quit": "Quitter", + "undo": "Annuler", + "redo": "Rétablir", + "cut": "Couper", + "copy": "Copier", + "paste": "Coller", + "select_all": "Tout sélectionner", + "search": "Rechercher dans les messages", + "refresh": "Relever le courrier", + "reading_pane": "Volet de lecture", + "pane_right": "À droite", + "pane_below": "Sous la liste", + "pane_off": "Désactivé", + "zoom_in": "Zoom avant", + "zoom_out": "Zoom arrière", + "zoom_reset": "Taille réelle", + "full_screen": "Plein écran", + "open_in_window": "Ouvrir dans une nouvelle fenêtre", + "next_message": "Message suivant", + "previous_message": "Message précédent", + "next_unread": "Prochain message non lu", + "mail": "Courrier", + "files": "Fichiers", + "back": "Précédent", + "forward": "Suivant", + "minimize": "Réduire", + "next_window": "Fenêtre suivante", + "shortcuts": "Raccourcis clavier", + "docs": "Documentation", + "settings": "Paramètres…", + "about": "À propos de Bulwark", + "from_plugins": "Plus d’options des modules…", + "pick_gone": "Ce menu n’était plus à jour. Rouvrez-le et réessayez." + }, + "context": { + "add_to_dictionary": "Ajouter « {word} » au dictionnaire", + "no_suggestions": "Aucune suggestion", + "open_link": "Ouvrir le lien", + "copy_link": "Copier le lien", + "copy_address": "Copier l’adresse", + "save_image": "Enregistrer l’image…", + "copy_image": "Copier l’image" + }, + "closing": { + "unsaved_title": "Un brouillon n’a pas pu être enregistré", + "unsaved_detail": "Si vous fermez maintenant, ce que vous avez écrit depuis le dernier enregistrement sera perdu.", + "close_anyway": "Fermer quand même", + "unsent_title": "Des messages n’ont pas encore été envoyés", + "unsent_one": "Un message attend d’être envoyé. Il partira à la prochaine ouverture de Bulwark.", + "unsent_many": "{count} messages attendent d’être envoyés. Ils partiront à la prochaine ouverture de Bulwark.", + "quit": "Quitter", + "leave_title": "Quitter sans enregistrer les modifications ?", + "leave_detail": "Vos modifications sur cette page ne sont pas encore enregistrées.", + "leave": "Quitter la page", + "stay": "Rester" + }, + "spellcheck": { + "offer": "La correction orthographique en {language} nécessite un dictionnaire. Bulwark peut le télécharger depuis Google. Votre texte n’est pas envoyé.", + "download": "Télécharger" + }, + "restore": { + "unavailable_title": "Ce brouillon ne peut pas être ouvert pour le moment", + "unavailable_body": "Il est en sécurité sur le serveur, dans Brouillons. Réessayez quand la connexion sera rétablie.", + "try_again": "Réessayer", + "close": "Fermer" } }, "desktop_sign_in": { diff --git a/locales/he/common.json b/locales/he/common.json index 8ca0148b4..cf7d7cafc 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6559,6 +6559,85 @@ "save_as": "שמירה בשם…", "copy": "העתקה", "copy_failed": "לא ניתן היה להעתיק את הקובץ." + }, + "menu": { + "file": "קובץ", + "edit": "עריכה", + "view": "תצוגה", + "message": "הודעה", + "go": "מעבר", + "window": "חלון", + "help": "עזרה", + "new_window": "חלון חדש", + "print": "הדפסה…", + "save_pdf": "שמירה כ-PDF…", + "switch_connection": "שינוי חיבור…", + "close": "סגירה", + "quit": "יציאה", + "undo": "ביטול", + "redo": "ביצוע חוזר", + "cut": "גזירה", + "copy": "העתקה", + "paste": "הדבקה", + "select_all": "בחירת הכול", + "search": "חיפוש בדואר", + "refresh": "בדיקת דואר חדש", + "reading_pane": "חלונית קריאה", + "pane_right": "מימין", + "pane_below": "מתחת לרשימה", + "pane_off": "כבויה", + "zoom_in": "הגדלה", + "zoom_out": "הקטנה", + "zoom_reset": "גודל בפועל", + "full_screen": "מסך מלא", + "open_in_window": "פתיחה בחלון חדש", + "next_message": "ההודעה הבאה", + "previous_message": "ההודעה הקודמת", + "next_unread": "ההודעה הבאה שלא נקראה", + "mail": "דואר", + "files": "קבצים", + "back": "חזרה", + "forward": "קדימה", + "minimize": "מזעור", + "next_window": "החלון הבא", + "shortcuts": "קיצורי מקלדת", + "docs": "תיעוד", + "settings": "הגדרות…", + "about": "מידע על Bulwark", + "from_plugins": "עוד מהתוספים…", + "pick_gone": "התפריט הזה היה מיושן. פתחו אותו שוב ונסו פעם נוספת." + }, + "context": { + "add_to_dictionary": "הוספת “{word}” למילון", + "no_suggestions": "אין הצעות", + "open_link": "פתיחת קישור", + "copy_link": "העתקת קישור", + "copy_address": "העתקת כתובת", + "save_image": "שמירת תמונה…", + "copy_image": "העתקת תמונה" + }, + "closing": { + "unsaved_title": "לא ניתן היה לשמור טיוטה", + "unsaved_detail": "אם תסגרו עכשיו, מה שכתבתם מאז השמירה האחרונה יאבד.", + "close_anyway": "לסגור בכל זאת", + "unsent_title": "יש הודעות שעדיין לא נשלחו", + "unsent_one": "הודעה אחת ממתינה לשליחה. היא תישלח בפעם הבאה שתפתחו את Bulwark.", + "unsent_many": "{count} הודעות ממתינות לשליחה. הן יישלחו בפעם הבאה שתפתחו את Bulwark.", + "quit": "יציאה", + "leave_title": "לעזוב עם שינויים שלא נשמרו?", + "leave_detail": "השינויים שביצעתם בדף הזה עדיין לא נשמרו.", + "leave": "עזיבה", + "stay": "להישאר" + }, + "spellcheck": { + "offer": "בדיקת איות ב{language} דורשת מילון. Bulwark יכול להוריד אותו מ-Google. הטקסט שלכם לא נשלח.", + "download": "הורדה" + }, + "restore": { + "unavailable_title": "אי אפשר לפתוח את הטיוטה הזאת כרגע", + "unavailable_body": "היא שמורה בשרת, בתיקיית הטיוטות. נסו שוב כשהחיבור יחזור.", + "try_again": "ניסיון חוזר", + "close": "סגירה" } }, "desktop_sign_in": { diff --git a/locales/hu/common.json b/locales/hu/common.json index 0459a57b5..8de7b9d63 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6558,6 +6558,85 @@ "save_as": "Mentés másként…", "copy": "Másolás", "copy_failed": "A fájlt nem sikerült másolni." + }, + "menu": { + "file": "Fájl", + "edit": "Szerkesztés", + "view": "Nézet", + "message": "Üzenet", + "go": "Ugrás", + "window": "Ablak", + "help": "Súgó", + "new_window": "Új ablak", + "print": "Nyomtatás…", + "save_pdf": "Mentés PDF-ként…", + "switch_connection": "Kapcsolat váltása…", + "close": "Bezárás", + "quit": "Kilépés", + "undo": "Visszavonás", + "redo": "Ismétlés", + "cut": "Kivágás", + "copy": "Másolás", + "paste": "Beillesztés", + "select_all": "Összes kijelölése", + "search": "Keresés a levelek között", + "refresh": "Új levelek lekérése", + "reading_pane": "Olvasópanel", + "pane_right": "Jobbra", + "pane_below": "A lista alatt", + "pane_off": "Ki", + "zoom_in": "Nagyítás", + "zoom_out": "Kicsinyítés", + "zoom_reset": "Tényleges méret", + "full_screen": "Teljes képernyő", + "open_in_window": "Megnyitás új ablakban", + "next_message": "Következő üzenet", + "previous_message": "Előző üzenet", + "next_unread": "Következő olvasatlan üzenet", + "mail": "Levelek", + "files": "Fájlok", + "back": "Vissza", + "forward": "Előre", + "minimize": "Minimalizálás", + "next_window": "Következő ablak", + "shortcuts": "Billentyűparancsok", + "docs": "Dokumentáció", + "settings": "Beállítások…", + "about": "A Bulwark névjegye", + "from_plugins": "Továbbiak a bővítményektől…", + "pick_gone": "Ez a menü elavult volt. Nyissa meg újra, és próbálja meg még egyszer." + }, + "context": { + "add_to_dictionary": "„{word}” hozzáadása a szótárhoz", + "no_suggestions": "Nincs javaslat", + "open_link": "Hivatkozás megnyitása", + "copy_link": "Hivatkozás másolása", + "copy_address": "Cím másolása", + "save_image": "Kép mentése…", + "copy_image": "Kép másolása" + }, + "closing": { + "unsaved_title": "Egy piszkozatot nem sikerült menteni", + "unsaved_detail": "Ha most bezárja, elvész, amit az utolsó mentés óta írt.", + "close_anyway": "Bezárás mégis", + "unsent_title": "Néhány levél még nincs elküldve", + "unsent_one": "Egy üzenet küldésre vár. A Bulwark következő megnyitásakor elmegy.", + "unsent_many": "{count} üzenet küldésre vár. A Bulwark következő megnyitásakor elmennek.", + "quit": "Kilépés", + "leave_title": "Elhagyja az oldalt mentetlen módosításokkal?", + "leave_detail": "Az oldalon végzett módosítások még nincsenek mentve.", + "leave": "Elhagyás", + "stay": "Maradás" + }, + "spellcheck": { + "offer": "A(z) {language} nyelvű helyesírás-ellenőrzéshez szótár kell. A Bulwark letöltheti a Google-tól. A szövege nem kerül elküldésre.", + "download": "Letöltés" + }, + "restore": { + "unavailable_title": "Ez a piszkozat most nem nyitható meg", + "unavailable_body": "Biztonságban van a kiszolgálón, a Piszkozatok mappában. Próbálja újra, ha visszatért a kapcsolat.", + "try_again": "Újrapróbálás", + "close": "Bezárás" } }, "desktop_sign_in": { diff --git a/locales/it/common.json b/locales/it/common.json index 95db2212c..a5a66b6ab 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6558,6 +6558,85 @@ "save_as": "Salva con nome…", "copy": "Copia", "copy_failed": "Impossibile copiare il file." + }, + "menu": { + "file": "File", + "edit": "Modifica", + "view": "Vista", + "message": "Messaggio", + "go": "Vai", + "window": "Finestra", + "help": "Aiuto", + "new_window": "Nuova finestra", + "print": "Stampa…", + "save_pdf": "Salva come PDF…", + "switch_connection": "Cambia connessione…", + "close": "Chiudi", + "quit": "Esci", + "undo": "Annulla", + "redo": "Ripeti", + "cut": "Taglia", + "copy": "Copia", + "paste": "Incolla", + "select_all": "Seleziona tutto", + "search": "Cerca nella posta", + "refresh": "Controlla nuova posta", + "reading_pane": "Pannello di lettura", + "pane_right": "A destra", + "pane_below": "Sotto l’elenco", + "pane_off": "Disattivato", + "zoom_in": "Ingrandisci", + "zoom_out": "Riduci", + "zoom_reset": "Dimensione effettiva", + "full_screen": "Schermo intero", + "open_in_window": "Apri in una nuova finestra", + "next_message": "Messaggio successivo", + "previous_message": "Messaggio precedente", + "next_unread": "Prossimo messaggio non letto", + "mail": "Posta", + "files": "File", + "back": "Indietro", + "forward": "Avanti", + "minimize": "Riduci a icona", + "next_window": "Finestra successiva", + "shortcuts": "Scorciatoie da tastiera", + "docs": "Documentazione", + "settings": "Impostazioni…", + "about": "Informazioni su Bulwark", + "from_plugins": "Altro dai plugin…", + "pick_gone": "Questo menu non era aggiornato. Riaprilo e riprova." + }, + "context": { + "add_to_dictionary": "Aggiungi «{word}» al dizionario", + "no_suggestions": "Nessun suggerimento", + "open_link": "Apri link", + "copy_link": "Copia link", + "copy_address": "Copia indirizzo", + "save_image": "Salva immagine…", + "copy_image": "Copia immagine" + }, + "closing": { + "unsaved_title": "Impossibile salvare una bozza", + "unsaved_detail": "Se chiudi ora, quello che hai scritto dall’ultimo salvataggio andrà perso.", + "close_anyway": "Chiudi comunque", + "unsent_title": "Alcuni messaggi non sono ancora stati inviati", + "unsent_one": "Un messaggio è in attesa di invio. Partirà alla prossima apertura di Bulwark.", + "unsent_many": "{count} messaggi sono in attesa di invio. Partiranno alla prossima apertura di Bulwark.", + "quit": "Esci", + "leave_title": "Uscire con modifiche non salvate?", + "leave_detail": "Le modifiche fatte in questa pagina non sono ancora salvate.", + "leave": "Esci", + "stay": "Rimani" + }, + "spellcheck": { + "offer": "Il controllo ortografico in {language} richiede un dizionario. Bulwark può scaricarlo da Google. Il tuo testo non viene inviato.", + "download": "Scarica" + }, + "restore": { + "unavailable_title": "Questa bozza non può essere aperta ora", + "unavailable_body": "È al sicuro sul server, in Bozze. Riprova quando la connessione sarà tornata.", + "try_again": "Riprova", + "close": "Chiudi" } }, "desktop_sign_in": { diff --git a/locales/ja/common.json b/locales/ja/common.json index 78070a2f8..47229b06d 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6558,6 +6558,85 @@ "save_as": "名前を付けて保存…", "copy": "コピー", "copy_failed": "ファイルをコピーできませんでした。" + }, + "menu": { + "file": "ファイル", + "edit": "編集", + "view": "表示", + "message": "メッセージ", + "go": "移動", + "window": "ウインドウ", + "help": "ヘルプ", + "new_window": "新規ウインドウ", + "print": "印刷…", + "save_pdf": "PDFとして保存…", + "switch_connection": "接続を変更…", + "close": "閉じる", + "quit": "終了", + "undo": "取り消す", + "redo": "やり直す", + "cut": "切り取り", + "copy": "コピー", + "paste": "貼り付け", + "select_all": "すべてを選択", + "search": "メールを検索", + "refresh": "新着メールを確認", + "reading_pane": "閲覧ウインドウ", + "pane_right": "右側", + "pane_below": "リストの下", + "pane_off": "オフ", + "zoom_in": "拡大", + "zoom_out": "縮小", + "zoom_reset": "実際のサイズ", + "full_screen": "フルスクリーン", + "open_in_window": "新規ウインドウで開く", + "next_message": "次のメッセージ", + "previous_message": "前のメッセージ", + "next_unread": "次の未読メッセージ", + "mail": "メール", + "files": "ファイル", + "back": "戻る", + "forward": "進む", + "minimize": "最小化", + "next_window": "次のウインドウ", + "shortcuts": "キーボードショートカット", + "docs": "ドキュメント", + "settings": "設定…", + "about": "Bulwarkについて", + "from_plugins": "プラグインのその他の項目…", + "pick_gone": "このメニューは古くなっています。もう一度開いて、やり直してください。" + }, + "context": { + "add_to_dictionary": "「{word}」を辞書に追加", + "no_suggestions": "候補なし", + "open_link": "リンクを開く", + "copy_link": "リンクをコピー", + "copy_address": "アドレスをコピー", + "save_image": "画像を保存…", + "copy_image": "画像をコピー" + }, + "closing": { + "unsaved_title": "下書きを保存できませんでした", + "unsaved_detail": "今閉じると、最後に保存した後に書いた内容は失われます。", + "close_anyway": "このまま閉じる", + "unsent_title": "未送信のメールがあります", + "unsent_one": "1件のメッセージが送信を待っています。次にBulwarkを開いたときに送信されます。", + "unsent_many": "{count}件のメッセージが送信を待っています。次にBulwarkを開いたときに送信されます。", + "quit": "終了", + "leave_title": "保存されていない変更を残して移動しますか?", + "leave_detail": "このページで行った変更はまだ保存されていません。", + "leave": "移動する", + "stay": "とどまる" + }, + "spellcheck": { + "offer": "{language}のスペルチェックには辞書が必要です。BulwarkはGoogleから辞書をダウンロードできます。入力した文章は送信されません。", + "download": "ダウンロード" + }, + "restore": { + "unavailable_title": "この下書きは今は開けません", + "unavailable_body": "下書きはサーバーの「下書き」に安全に保管されています。接続が戻ってから、もう一度お試しください。", + "try_again": "再試行", + "close": "閉じる" } }, "desktop_sign_in": { diff --git a/locales/ko/common.json b/locales/ko/common.json index 96a87153f..ea74e31b0 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6558,6 +6558,85 @@ "save_as": "다른 이름으로 저장…", "copy": "복사", "copy_failed": "파일을 복사할 수 없습니다." + }, + "menu": { + "file": "파일", + "edit": "편집", + "view": "보기", + "message": "메시지", + "go": "이동", + "window": "창", + "help": "도움말", + "new_window": "새 창", + "print": "인쇄…", + "save_pdf": "PDF로 저장…", + "switch_connection": "연결 변경…", + "close": "닫기", + "quit": "종료", + "undo": "실행 취소", + "redo": "다시 실행", + "cut": "잘라내기", + "copy": "복사", + "paste": "붙여넣기", + "select_all": "모두 선택", + "search": "메일 검색", + "refresh": "새 메일 확인", + "reading_pane": "읽기 창", + "pane_right": "오른쪽", + "pane_below": "목록 아래", + "pane_off": "끄기", + "zoom_in": "확대", + "zoom_out": "축소", + "zoom_reset": "실제 크기", + "full_screen": "전체 화면", + "open_in_window": "새 창에서 열기", + "next_message": "다음 메시지", + "previous_message": "이전 메시지", + "next_unread": "다음 읽지 않은 메시지", + "mail": "메일", + "files": "파일", + "back": "뒤로", + "forward": "앞으로", + "minimize": "최소화", + "next_window": "다음 창", + "shortcuts": "키보드 단축키", + "docs": "문서", + "settings": "설정…", + "about": "Bulwark 정보", + "from_plugins": "플러그인 더 보기…", + "pick_gone": "이 메뉴는 오래된 것입니다. 다시 열어 한 번 더 시도해 주세요." + }, + "context": { + "add_to_dictionary": "“{word}”을(를) 사전에 추가", + "no_suggestions": "추천 없음", + "open_link": "링크 열기", + "copy_link": "링크 복사", + "copy_address": "주소 복사", + "save_image": "이미지 저장…", + "copy_image": "이미지 복사" + }, + "closing": { + "unsaved_title": "임시 보관 메일을 저장하지 못했습니다", + "unsaved_detail": "지금 닫으면 마지막으로 저장한 이후에 작성한 내용이 사라집니다.", + "close_anyway": "그래도 닫기", + "unsent_title": "아직 보내지 않은 메일이 있습니다", + "unsent_one": "메시지 1개가 전송을 기다리고 있습니다. 다음에 Bulwark를 열 때 전송됩니다.", + "unsent_many": "메시지 {count}개가 전송을 기다리고 있습니다. 다음에 Bulwark를 열 때 전송됩니다.", + "quit": "종료", + "leave_title": "저장하지 않은 변경 사항이 있습니다. 나가시겠습니까?", + "leave_detail": "이 페이지에서 변경한 내용이 아직 저장되지 않았습니다.", + "leave": "나가기", + "stay": "머무르기" + }, + "spellcheck": { + "offer": "{language} 맞춤법 검사에는 사전이 필요합니다. Bulwark가 Google에서 내려받을 수 있습니다. 입력한 텍스트는 전송되지 않습니다.", + "download": "내려받기" + }, + "restore": { + "unavailable_title": "지금은 이 임시 보관 메일을 열 수 없습니다", + "unavailable_body": "서버의 임시 보관함에 안전하게 있습니다. 연결이 돌아오면 다시 시도해 주세요.", + "try_again": "다시 시도", + "close": "닫기" } }, "desktop_sign_in": { diff --git a/locales/lv/common.json b/locales/lv/common.json index 1ca156d61..c78516d51 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6558,6 +6558,85 @@ "save_as": "Saglabāt kā…", "copy": "Kopēt", "copy_failed": "Failu neizdevās nokopēt." + }, + "menu": { + "file": "Fails", + "edit": "Rediģēt", + "view": "Skats", + "message": "Ziņojums", + "go": "Doties uz", + "window": "Logs", + "help": "Palīdzība", + "new_window": "Jauns logs", + "print": "Drukāt…", + "save_pdf": "Saglabāt kā PDF…", + "switch_connection": "Mainīt savienojumu…", + "close": "Aizvērt", + "quit": "Iziet", + "undo": "Atsaukt", + "redo": "Atkārtot", + "cut": "Izgriezt", + "copy": "Kopēt", + "paste": "Ielīmēt", + "select_all": "Atlasīt visu", + "search": "Meklēt pastā", + "refresh": "Pārbaudīt jaunu pastu", + "reading_pane": "Lasīšanas rūts", + "pane_right": "Labajā pusē", + "pane_below": "Zem saraksta", + "pane_off": "Izslēgta", + "zoom_in": "Tuvināt", + "zoom_out": "Tālināt", + "zoom_reset": "Faktiskais izmērs", + "full_screen": "Pilnekrāna režīms", + "open_in_window": "Atvērt jaunā logā", + "next_message": "Nākamais ziņojums", + "previous_message": "Iepriekšējais ziņojums", + "next_unread": "Nākamais nelasītais ziņojums", + "mail": "Pasts", + "files": "Faili", + "back": "Atpakaļ", + "forward": "Uz priekšu", + "minimize": "Minimizēt", + "next_window": "Nākamais logs", + "shortcuts": "Tastatūras īsinājumtaustiņi", + "docs": "Dokumentācija", + "settings": "Iestatījumi…", + "about": "Par Bulwark", + "from_plugins": "Vairāk no spraudņiem…", + "pick_gone": "Šī izvēlne bija novecojusi. Atveriet to vēlreiz un mēģiniet vēlreiz." + }, + "context": { + "add_to_dictionary": "Pievienot “{word}” vārdnīcai", + "no_suggestions": "Nav ieteikumu", + "open_link": "Atvērt saiti", + "copy_link": "Kopēt saiti", + "copy_address": "Kopēt adresi", + "save_image": "Saglabāt attēlu…", + "copy_image": "Kopēt attēlu" + }, + "closing": { + "unsaved_title": "Melnrakstu neizdevās saglabāt", + "unsaved_detail": "Ja aizvērsiet tagad, kas uzrakstīts kopš pēdējās saglabāšanas, tiks zaudēts.", + "close_anyway": "Tomēr aizvērt", + "unsent_title": "Daļa pasta vēl nav nosūtīta", + "unsent_one": "Viens ziņojums gaida nosūtīšanu. Tas tiks nosūtīts nākamreiz, kad atvērsiet Bulwark.", + "unsent_many": "Ziņojumi, kas gaida nosūtīšanu: {count}. Tie tiks nosūtīti nākamreiz, kad atvērsiet Bulwark.", + "quit": "Iziet", + "leave_title": "Pamest lapu ar nesaglabātām izmaiņām?", + "leave_detail": "Šajā lapā veiktās izmaiņas vēl nav saglabātas.", + "leave": "Pamest", + "stay": "Palikt" + }, + "spellcheck": { + "offer": "Pareizrakstības pārbaudei valodā {language} nepieciešama vārdnīca. Bulwark to var lejupielādēt no Google. Jūsu teksts netiek sūtīts.", + "download": "Lejupielādēt" + }, + "restore": { + "unavailable_title": "Šo melnrakstu pašlaik nevar atvērt", + "unavailable_body": "Tas ir drošībā serverī, mapē Melnraksti. Mēģiniet vēlreiz, kad savienojums būs atjaunots.", + "try_again": "Mēģināt vēlreiz", + "close": "Aizvērt" } }, "desktop_sign_in": { diff --git a/locales/mn/common.json b/locales/mn/common.json index 52cc39b48..955bb2cc8 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6558,6 +6558,85 @@ "save_as": "Өөрөөр хадгалах…", "copy": "Хуулах", "copy_failed": "Файлыг хуулж чадсангүй." + }, + "menu": { + "file": "Файл", + "edit": "Засах", + "view": "Харах", + "message": "Зурвас", + "go": "Очих", + "window": "Цонх", + "help": "Тусламж", + "new_window": "Шинэ цонх", + "print": "Хэвлэх…", + "save_pdf": "PDF хэлбэрээр хадгалах…", + "switch_connection": "Холболт солих…", + "close": "Хаах", + "quit": "Гарах", + "undo": "Буцаах", + "redo": "Дахин хийх", + "cut": "Хайчлах", + "copy": "Хуулах", + "paste": "Буулгах", + "select_all": "Бүгдийг сонгох", + "search": "Захидлаас хайх", + "refresh": "Шинэ захидал шалгах", + "reading_pane": "Унших хэсэг", + "pane_right": "Баруун талд", + "pane_below": "Жагсаалтын доор", + "pane_off": "Идэвхгүй", + "zoom_in": "Томруулах", + "zoom_out": "Жижигрүүлэх", + "zoom_reset": "Бодит хэмжээ", + "full_screen": "Бүтэн дэлгэц", + "open_in_window": "Шинэ цонхонд нээх", + "next_message": "Дараагийн зурвас", + "previous_message": "Өмнөх зурвас", + "next_unread": "Дараагийн уншаагүй зурвас", + "mail": "Имэйл", + "files": "Файлууд", + "back": "Буцах", + "forward": "Урагш", + "minimize": "Жижигрүүлэх", + "next_window": "Дараагийн цонх", + "shortcuts": "Гарын товчлол", + "docs": "Баримт бичиг", + "settings": "Тохиргоо…", + "about": "Bulwark-ийн тухай", + "from_plugins": "Залгаснуудын бусад зүйл…", + "pick_gone": "Энэ цэс хуучирсан байна. Дахин нээгээд нэг удаа оролдоно уу." + }, + "context": { + "add_to_dictionary": "“{word}”-ийг толь бичигт нэмэх", + "no_suggestions": "Санал алга", + "open_link": "Холбоос нээх", + "copy_link": "Холбоос хуулах", + "copy_address": "Хаяг хуулах", + "save_image": "Зураг хадгалах…", + "copy_image": "Зураг хуулах" + }, + "closing": { + "unsaved_title": "Ноорогийг хадгалж чадсангүй", + "unsaved_detail": "Одоо хаавал сүүлд хадгалснаас хойш бичсэн зүйл тань алдагдана.", + "close_anyway": "Ямартай ч хаах", + "unsent_title": "Зарим захидал хараахан илгээгдээгүй байна", + "unsent_one": "Нэг зурвас илгээгдэхийг хүлээж байна. Bulwark-ийг дараа нээхэд илгээгдэнэ.", + "unsent_many": "{count} зурвас илгээгдэхийг хүлээж байна. Bulwark-ийг дараа нээхэд илгээгдэнэ.", + "quit": "Гарах", + "leave_title": "Хадгалаагүй өөрчлөлттэй гарах уу?", + "leave_detail": "Энэ хуудсанд хийсэн өөрчлөлт тань хараахан хадгалагдаагүй байна.", + "leave": "Гарах", + "stay": "Үлдэх" + }, + "spellcheck": { + "offer": "{language} хэлний үсгийн алдаа шалгагчид толь бичиг хэрэгтэй. Bulwark үүнийг Google-ээс татаж авч чадна. Таны бичвэр илгээгдэхгүй.", + "download": "Татах" + }, + "restore": { + "unavailable_title": "Энэ ноорогийг одоо нээх боломжгүй", + "unavailable_body": "Ноорог серверийн Ноорог хавтсанд аюулгүй байна. Холболт сэргэхэд дахин оролдоно уу.", + "try_again": "Дахин оролдох", + "close": "Хаах" } }, "desktop_sign_in": { diff --git a/locales/nb/common.json b/locales/nb/common.json index 0d7083171..a7ebdcb30 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6558,6 +6558,85 @@ "save_as": "Lagre som…", "copy": "Kopier", "copy_failed": "Filen kunne ikke kopieres." + }, + "menu": { + "file": "Fil", + "edit": "Rediger", + "view": "Vis", + "message": "Melding", + "go": "Gå", + "window": "Vindu", + "help": "Hjelp", + "new_window": "Nytt vindu", + "print": "Skriv ut…", + "save_pdf": "Lagre som PDF…", + "switch_connection": "Bytt tilkobling…", + "close": "Lukk", + "quit": "Avslutt", + "undo": "Angre", + "redo": "Gjør om", + "cut": "Klipp ut", + "copy": "Kopier", + "paste": "Lim inn", + "select_all": "Velg alt", + "search": "Søk i e-post", + "refresh": "Se etter ny e-post", + "reading_pane": "Leserute", + "pane_right": "Til høyre", + "pane_below": "Under listen", + "pane_off": "Av", + "zoom_in": "Zoom inn", + "zoom_out": "Zoom ut", + "zoom_reset": "Faktisk størrelse", + "full_screen": "Fullskjerm", + "open_in_window": "Åpne i nytt vindu", + "next_message": "Neste melding", + "previous_message": "Forrige melding", + "next_unread": "Neste uleste melding", + "mail": "E-post", + "files": "Filer", + "back": "Tilbake", + "forward": "Fremover", + "minimize": "Minimer", + "next_window": "Neste vindu", + "shortcuts": "Tastatursnarveier", + "docs": "Dokumentasjon", + "settings": "Innstillinger…", + "about": "Om Bulwark", + "from_plugins": "Mer fra utvidelser…", + "pick_gone": "Den menyen var utdatert. Åpne den på nytt og prøv igjen." + }, + "context": { + "add_to_dictionary": "Legg til «{word}» i ordboken", + "no_suggestions": "Ingen forslag", + "open_link": "Åpne lenke", + "copy_link": "Kopier lenke", + "copy_address": "Kopier adresse", + "save_image": "Lagre bilde…", + "copy_image": "Kopier bilde" + }, + "closing": { + "unsaved_title": "Et utkast kunne ikke lagres", + "unsaved_detail": "Hvis du lukker nå, går det du har skrevet siden forrige lagring tapt.", + "close_anyway": "Lukk likevel", + "unsent_title": "Noe e-post er ikke sendt ennå", + "unsent_one": "Én melding venter på å bli sendt. Den sendes neste gang du åpner Bulwark.", + "unsent_many": "{count} meldinger venter på å bli sendt. De sendes neste gang du åpner Bulwark.", + "quit": "Avslutt", + "leave_title": "Forlate siden med ulagrede endringer?", + "leave_detail": "Endringene dine på denne siden er ikke lagret ennå.", + "leave": "Forlat", + "stay": "Bli" + }, + "spellcheck": { + "offer": "Stavekontroll for {language} trenger en ordbok. Bulwark kan laste den ned fra Google. Teksten din blir ikke sendt.", + "download": "Last ned" + }, + "restore": { + "unavailable_title": "Dette utkastet kan ikke åpnes akkurat nå", + "unavailable_body": "Det er trygt på serveren, i Utkast. Prøv igjen når tilkoblingen er tilbake.", + "try_again": "Prøv igjen", + "close": "Lukk" } }, "desktop_sign_in": { diff --git a/locales/nl/common.json b/locales/nl/common.json index b0e75ada3..7dfec60f3 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6558,6 +6558,85 @@ "save_as": "Opslaan als…", "copy": "Kopiëren", "copy_failed": "Het bestand kon niet worden gekopieerd." + }, + "menu": { + "file": "Bestand", + "edit": "Bewerken", + "view": "Weergave", + "message": "Bericht", + "go": "Ga", + "window": "Venster", + "help": "Help", + "new_window": "Nieuw venster", + "print": "Afdrukken…", + "save_pdf": "Bewaar als pdf…", + "switch_connection": "Verbinding wijzigen…", + "close": "Sluit", + "quit": "Stop", + "undo": "Herstel", + "redo": "Opnieuw", + "cut": "Knip", + "copy": "Kopieer", + "paste": "Plak", + "select_all": "Selecteer alles", + "search": "Zoek in e-mail", + "refresh": "Controleer op nieuwe e-mail", + "reading_pane": "Leesvenster", + "pane_right": "Rechts", + "pane_below": "Onder de lijst", + "pane_off": "Uit", + "zoom_in": "Zoom in", + "zoom_out": "Zoom uit", + "zoom_reset": "Werkelijke grootte", + "full_screen": "Volledig scherm", + "open_in_window": "Open in nieuw venster", + "next_message": "Volgend bericht", + "previous_message": "Vorig bericht", + "next_unread": "Volgend ongelezen bericht", + "mail": "E-mail", + "files": "Bestanden", + "back": "Terug", + "forward": "Vooruit", + "minimize": "Minimaliseer", + "next_window": "Volgend venster", + "shortcuts": "Toetsenbordsneltoetsen", + "docs": "Documentatie", + "settings": "Instellingen…", + "about": "Over Bulwark", + "from_plugins": "Meer van plug-ins…", + "pick_gone": "Dat menu was verouderd. Open het opnieuw en probeer het nog eens." + }, + "context": { + "add_to_dictionary": "Voeg ‘{word}’ toe aan woordenboek", + "no_suggestions": "Geen suggesties", + "open_link": "Open link", + "copy_link": "Kopieer link", + "copy_address": "Kopieer adres", + "save_image": "Bewaar afbeelding…", + "copy_image": "Kopieer afbeelding" + }, + "closing": { + "unsaved_title": "Een concept kon niet worden bewaard", + "unsaved_detail": "Als u nu sluit, gaat verloren wat u sinds de laatste keer bewaren hebt geschreven.", + "close_anyway": "Toch sluiten", + "unsent_title": "Er is e-mail die nog niet is verzonden", + "unsent_one": "Eén bericht wacht om te worden verzonden. Het wordt verzonden de volgende keer dat u Bulwark opent.", + "unsent_many": "{count} berichten wachten om te worden verzonden. Ze worden verzonden de volgende keer dat u Bulwark opent.", + "quit": "Stop", + "leave_title": "Verlaten met niet-bewaarde wijzigingen?", + "leave_detail": "Uw wijzigingen op deze pagina zijn nog niet bewaard.", + "leave": "Verlaat", + "stay": "Blijf" + }, + "spellcheck": { + "offer": "Voor spellingcontrole in het {language} is een woordenboek nodig. Bulwark kan dit downloaden van Google. Uw tekst wordt niet verzonden.", + "download": "Download" + }, + "restore": { + "unavailable_title": "Dit concept kan nu niet worden geopend", + "unavailable_body": "Het staat veilig op de server, in Concepten. Probeer het opnieuw zodra de verbinding terug is.", + "try_again": "Probeer opnieuw", + "close": "Sluit" } }, "desktop_sign_in": { diff --git a/locales/pl/common.json b/locales/pl/common.json index eb4f9f8fc..628f0113e 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6558,6 +6558,85 @@ "save_as": "Zapisz jako…", "copy": "Kopiuj", "copy_failed": "Nie udało się skopiować pliku." + }, + "menu": { + "file": "Plik", + "edit": "Edycja", + "view": "Widok", + "message": "Wiadomość", + "go": "Przejdź", + "window": "Okno", + "help": "Pomoc", + "new_window": "Nowe okno", + "print": "Drukuj…", + "save_pdf": "Zapisz jako PDF…", + "switch_connection": "Zmień połączenie…", + "close": "Zamknij", + "quit": "Zakończ", + "undo": "Cofnij", + "redo": "Ponów", + "cut": "Wytnij", + "copy": "Kopiuj", + "paste": "Wklej", + "select_all": "Zaznacz wszystko", + "search": "Szukaj w poczcie", + "refresh": "Sprawdź nową pocztę", + "reading_pane": "Okienko podglądu", + "pane_right": "Po prawej", + "pane_below": "Pod listą", + "pane_off": "Wyłączone", + "zoom_in": "Powiększ", + "zoom_out": "Pomniejsz", + "zoom_reset": "Rzeczywisty rozmiar", + "full_screen": "Pełny ekran", + "open_in_window": "Otwórz w nowym oknie", + "next_message": "Następna wiadomość", + "previous_message": "Poprzednia wiadomość", + "next_unread": "Następna nieprzeczytana wiadomość", + "mail": "Poczta", + "files": "Pliki", + "back": "Wstecz", + "forward": "Dalej", + "minimize": "Minimalizuj", + "next_window": "Następne okno", + "shortcuts": "Skróty klawiszowe", + "docs": "Dokumentacja", + "settings": "Ustawienia…", + "about": "O programie Bulwark", + "from_plugins": "Więcej z wtyczek…", + "pick_gone": "To menu było nieaktualne. Otwórz je ponownie i spróbuj jeszcze raz." + }, + "context": { + "add_to_dictionary": "Dodaj „{word}” do słownika", + "no_suggestions": "Brak podpowiedzi", + "open_link": "Otwórz łącze", + "copy_link": "Kopiuj łącze", + "copy_address": "Kopiuj adres", + "save_image": "Zapisz obraz…", + "copy_image": "Kopiuj obraz" + }, + "closing": { + "unsaved_title": "Nie udało się zapisać wersji roboczej", + "unsaved_detail": "Jeśli zamkniesz teraz, utracisz wszystko, co zostało napisane od ostatniego zapisu.", + "close_anyway": "Zamknij mimo to", + "unsent_title": "Część poczty nie została jeszcze wysłana", + "unsent_one": "Jedna wiadomość czeka na wysłanie. Zostanie wysłana przy następnym uruchomieniu Bulwark.", + "unsent_many": "Wiadomości czekające na wysłanie: {count}. Zostaną wysłane przy następnym uruchomieniu Bulwark.", + "quit": "Zakończ", + "leave_title": "Opuścić stronę bez zapisania zmian?", + "leave_detail": "Zmiany wprowadzone na tej stronie nie zostały jeszcze zapisane.", + "leave": "Opuść", + "stay": "Zostań" + }, + "spellcheck": { + "offer": "Sprawdzanie pisowni dla języka {language} wymaga słownika. Bulwark może go pobrać od Google. Twój tekst nie jest wysyłany.", + "download": "Pobierz" + }, + "restore": { + "unavailable_title": "Nie można teraz otworzyć tej wersji roboczej", + "unavailable_body": "Jest bezpieczna na serwerze, w folderze Wersje robocze. Spróbuj ponownie, gdy połączenie wróci.", + "try_again": "Spróbuj ponownie", + "close": "Zamknij" } }, "desktop_sign_in": { diff --git a/locales/pt/common.json b/locales/pt/common.json index 5235f5317..933c7141a 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6558,6 +6558,85 @@ "save_as": "Guardar como…", "copy": "Copiar", "copy_failed": "Não foi possível copiar o ficheiro." + }, + "menu": { + "file": "Ficheiro", + "edit": "Editar", + "view": "Ver", + "message": "Mensagem", + "go": "Ir", + "window": "Janela", + "help": "Ajuda", + "new_window": "Nova janela", + "print": "Imprimir…", + "save_pdf": "Guardar como PDF…", + "switch_connection": "Mudar de ligação…", + "close": "Fechar", + "quit": "Sair", + "undo": "Anular", + "redo": "Refazer", + "cut": "Cortar", + "copy": "Copiar", + "paste": "Colar", + "select_all": "Selecionar tudo", + "search": "Pesquisar no correio", + "refresh": "Verificar novo correio", + "reading_pane": "Painel de leitura", + "pane_right": "À direita", + "pane_below": "Abaixo da lista", + "pane_off": "Desativado", + "zoom_in": "Ampliar", + "zoom_out": "Reduzir", + "zoom_reset": "Tamanho real", + "full_screen": "Ecrã inteiro", + "open_in_window": "Abrir numa nova janela", + "next_message": "Mensagem seguinte", + "previous_message": "Mensagem anterior", + "next_unread": "Próxima mensagem por ler", + "mail": "Correio", + "files": "Ficheiros", + "back": "Anterior", + "forward": "Seguinte", + "minimize": "Minimizar", + "next_window": "Janela seguinte", + "shortcuts": "Atalhos de teclado", + "docs": "Documentação", + "settings": "Definições…", + "about": "Acerca do Bulwark", + "from_plugins": "Mais dos plugins…", + "pick_gone": "Esse menu estava desatualizado. Abra-o de novo e tente outra vez." + }, + "context": { + "add_to_dictionary": "Adicionar «{word}» ao dicionário", + "no_suggestions": "Sem sugestões", + "open_link": "Abrir ligação", + "copy_link": "Copiar ligação", + "copy_address": "Copiar endereço", + "save_image": "Guardar imagem…", + "copy_image": "Copiar imagem" + }, + "closing": { + "unsaved_title": "Não foi possível guardar um rascunho", + "unsaved_detail": "Se fechar agora, perde o que escreveu desde a última gravação.", + "close_anyway": "Fechar mesmo assim", + "unsent_title": "Há mensagens por enviar", + "unsent_one": "Uma mensagem aguarda envio. Será enviada na próxima vez que abrir o Bulwark.", + "unsent_many": "{count} mensagens aguardam envio. Serão enviadas na próxima vez que abrir o Bulwark.", + "quit": "Sair", + "leave_title": "Sair com alterações não guardadas?", + "leave_detail": "As alterações feitas nesta página ainda não foram guardadas.", + "leave": "Sair", + "stay": "Ficar" + }, + "spellcheck": { + "offer": "A verificação ortográfica em {language} precisa de um dicionário. O Bulwark pode transferi-lo da Google. O seu texto não é enviado.", + "download": "Transferir" + }, + "restore": { + "unavailable_title": "Este rascunho não pode ser aberto agora", + "unavailable_body": "Está seguro no servidor, em Rascunhos. Tente novamente quando a ligação voltar.", + "try_again": "Tentar novamente", + "close": "Fechar" } }, "desktop_sign_in": { diff --git a/locales/ro/common.json b/locales/ro/common.json index 5076823a5..f4e721732 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6558,6 +6558,85 @@ "save_as": "Salvează ca…", "copy": "Copiază", "copy_failed": "Fișierul nu a putut fi copiat." + }, + "menu": { + "file": "Fișier", + "edit": "Editare", + "view": "Vizualizare", + "message": "Mesaj", + "go": "Salt la", + "window": "Fereastră", + "help": "Ajutor", + "new_window": "Fereastră nouă", + "print": "Imprimare…", + "save_pdf": "Salvare ca PDF…", + "switch_connection": "Schimbare conexiune…", + "close": "Închidere", + "quit": "Ieșire", + "undo": "Anulare", + "redo": "Refacere", + "cut": "Decupare", + "copy": "Copiere", + "paste": "Lipire", + "select_all": "Selectare totală", + "search": "Căutare în e-mail", + "refresh": "Verificare e-mailuri noi", + "reading_pane": "Panou de citire", + "pane_right": "În dreapta", + "pane_below": "Sub listă", + "pane_off": "Dezactivat", + "zoom_in": "Mărire", + "zoom_out": "Micșorare", + "zoom_reset": "Dimensiune reală", + "full_screen": "Ecran complet", + "open_in_window": "Deschidere în fereastră nouă", + "next_message": "Mesajul următor", + "previous_message": "Mesajul anterior", + "next_unread": "Următorul mesaj necitit", + "mail": "E-mail", + "files": "Fișiere", + "back": "Înapoi", + "forward": "Înainte", + "minimize": "Minimizare", + "next_window": "Fereastra următoare", + "shortcuts": "Comenzi rapide de la tastatură", + "docs": "Documentație", + "settings": "Setări…", + "about": "Despre Bulwark", + "from_plugins": "Mai multe de la pluginuri…", + "pick_gone": "Meniul era depășit. Deschideți-l din nou și încercați încă o dată." + }, + "context": { + "add_to_dictionary": "Adăugare „{word}” în dicționar", + "no_suggestions": "Fără sugestii", + "open_link": "Deschidere link", + "copy_link": "Copiere link", + "copy_address": "Copiere adresă", + "save_image": "Salvare imagine…", + "copy_image": "Copiere imagine" + }, + "closing": { + "unsaved_title": "O ciornă nu a putut fi salvată", + "unsaved_detail": "Dacă închideți acum, ce ați scris de la ultima salvare se pierde.", + "close_anyway": "Închidere oricum", + "unsent_title": "Unele mesaje nu au fost trimise încă", + "unsent_one": "Un mesaj așteaptă să fie trimis. Pleacă la următoarea deschidere a Bulwark.", + "unsent_many": "{count} mesaje așteaptă să fie trimise. Pleacă la următoarea deschidere a Bulwark.", + "quit": "Ieșire", + "leave_title": "Părăsiți pagina cu modificări nesalvate?", + "leave_detail": "Modificările făcute pe această pagină nu au fost salvate încă.", + "leave": "Părăsire", + "stay": "Rămâneți" + }, + "spellcheck": { + "offer": "Verificarea ortografică pentru {language} are nevoie de un dicționar. Bulwark îl poate descărca de la Google. Textul dvs. nu este trimis.", + "download": "Descărcare" + }, + "restore": { + "unavailable_title": "Această ciornă nu poate fi deschisă acum", + "unavailable_body": "Este în siguranță pe server, în Ciorne. Încercați din nou când revine conexiunea.", + "try_again": "Încercați din nou", + "close": "Închidere" } }, "desktop_sign_in": { diff --git a/locales/ru/common.json b/locales/ru/common.json index 05ce8b7b8..ba407117a 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6558,6 +6558,85 @@ "save_as": "Сохранить как…", "copy": "Копировать", "copy_failed": "Не удалось скопировать файл." + }, + "menu": { + "file": "Файл", + "edit": "Правка", + "view": "Вид", + "message": "Сообщение", + "go": "Переход", + "window": "Окно", + "help": "Справка", + "new_window": "Новое окно", + "print": "Печать…", + "save_pdf": "Сохранить как PDF…", + "switch_connection": "Сменить подключение…", + "close": "Закрыть", + "quit": "Выйти", + "undo": "Отменить", + "redo": "Повторить", + "cut": "Вырезать", + "copy": "Копировать", + "paste": "Вставить", + "select_all": "Выделить всё", + "search": "Поиск по почте", + "refresh": "Проверить новую почту", + "reading_pane": "Область чтения", + "pane_right": "Справа", + "pane_below": "Под списком", + "pane_off": "Выключена", + "zoom_in": "Увеличить", + "zoom_out": "Уменьшить", + "zoom_reset": "Реальный размер", + "full_screen": "Полноэкранный режим", + "open_in_window": "Открыть в новом окне", + "next_message": "Следующее сообщение", + "previous_message": "Предыдущее сообщение", + "next_unread": "Следующее непрочитанное сообщение", + "mail": "Почта", + "files": "Файлы", + "back": "Назад", + "forward": "Вперёд", + "minimize": "Свернуть", + "next_window": "Следующее окно", + "shortcuts": "Сочетания клавиш", + "docs": "Документация", + "settings": "Настройки…", + "about": "О программе Bulwark", + "from_plugins": "Ещё из плагинов…", + "pick_gone": "Это меню устарело. Откройте его снова и повторите попытку." + }, + "context": { + "add_to_dictionary": "Добавить «{word}» в словарь", + "no_suggestions": "Нет вариантов", + "open_link": "Открыть ссылку", + "copy_link": "Копировать ссылку", + "copy_address": "Копировать адрес", + "save_image": "Сохранить изображение…", + "copy_image": "Копировать изображение" + }, + "closing": { + "unsaved_title": "Не удалось сохранить черновик", + "unsaved_detail": "Если закрыть сейчас, всё написанное после последнего сохранения будет потеряно.", + "close_anyway": "Всё равно закрыть", + "unsent_title": "Часть писем ещё не отправлена", + "unsent_one": "Одно сообщение ждёт отправки. Оно уйдёт при следующем запуске Bulwark.", + "unsent_many": "Сообщений, ожидающих отправки: {count}. Они уйдут при следующем запуске Bulwark.", + "quit": "Выйти", + "leave_title": "Уйти с несохранёнными изменениями?", + "leave_detail": "Изменения на этой странице ещё не сохранены.", + "leave": "Уйти", + "stay": "Остаться" + }, + "spellcheck": { + "offer": "Для проверки орфографии на языке «{language}» нужен словарь. Bulwark может скачать его у Google. Ваш текст не отправляется.", + "download": "Скачать" + }, + "restore": { + "unavailable_title": "Этот черновик сейчас нельзя открыть", + "unavailable_body": "Он в сохранности на сервере, в папке «Черновики». Повторите попытку, когда появится соединение.", + "try_again": "Повторить", + "close": "Закрыть" } }, "desktop_sign_in": { diff --git a/locales/sk/common.json b/locales/sk/common.json index 392915139..ff71bfbcb 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6558,6 +6558,85 @@ "save_as": "Uložiť ako…", "copy": "Kopírovať", "copy_failed": "Súbor sa nepodarilo skopírovať." + }, + "menu": { + "file": "Súbor", + "edit": "Upraviť", + "view": "Zobrazenie", + "message": "Správa", + "go": "Prejsť", + "window": "Okno", + "help": "Pomocník", + "new_window": "Nové okno", + "print": "Tlačiť…", + "save_pdf": "Uložiť ako PDF…", + "switch_connection": "Zmeniť pripojenie…", + "close": "Zavrieť", + "quit": "Ukončiť", + "undo": "Späť", + "redo": "Znova", + "cut": "Vystrihnúť", + "copy": "Kopírovať", + "paste": "Prilepiť", + "select_all": "Vybrať všetko", + "search": "Hľadať v pošte", + "refresh": "Skontrolovať novú poštu", + "reading_pane": "Panel čítania", + "pane_right": "Vpravo", + "pane_below": "Pod zoznamom", + "pane_off": "Vypnuté", + "zoom_in": "Priblížiť", + "zoom_out": "Oddialiť", + "zoom_reset": "Skutočná veľkosť", + "full_screen": "Celá obrazovka", + "open_in_window": "Otvoriť v novom okne", + "next_message": "Ďalšia správa", + "previous_message": "Predchádzajúca správa", + "next_unread": "Ďalšia neprečítaná správa", + "mail": "Pošta", + "files": "Súbory", + "back": "Späť", + "forward": "Dopredu", + "minimize": "Minimalizovať", + "next_window": "Ďalšie okno", + "shortcuts": "Klávesové skratky", + "docs": "Dokumentácia", + "settings": "Nastavenia…", + "about": "O aplikácii Bulwark", + "from_plugins": "Viac z doplnkov…", + "pick_gone": "Táto ponuka bola zastaraná. Otvorte ju znova a skúste to ešte raz." + }, + "context": { + "add_to_dictionary": "Pridať „{word}“ do slovníka", + "no_suggestions": "Žiadne návrhy", + "open_link": "Otvoriť odkaz", + "copy_link": "Kopírovať odkaz", + "copy_address": "Kopírovať adresu", + "save_image": "Uložiť obrázok…", + "copy_image": "Kopírovať obrázok" + }, + "closing": { + "unsaved_title": "Koncept sa nepodarilo uložiť", + "unsaved_detail": "Ak teraz zatvoríte okno, prídete o to, čo ste napísali od posledného uloženia.", + "close_anyway": "Napriek tomu zavrieť", + "unsent_title": "Niektorá pošta ešte nebola odoslaná", + "unsent_one": "Jedna správa čaká na odoslanie. Odíde pri ďalšom spustení Bulwark.", + "unsent_many": "Počet správ čakajúcich na odoslanie: {count}. Odídu pri ďalšom spustení Bulwark.", + "quit": "Ukončiť", + "leave_title": "Odísť s neuloženými zmenami?", + "leave_detail": "Zmeny na tejto stránke zatiaľ nie sú uložené.", + "leave": "Odísť", + "stay": "Zostať" + }, + "spellcheck": { + "offer": "Kontrola pravopisu pre jazyk {language} potrebuje slovník. Bulwark ho môže stiahnuť od spoločnosti Google. Váš text sa neodosiela.", + "download": "Stiahnuť" + }, + "restore": { + "unavailable_title": "Tento koncept sa teraz nedá otvoriť", + "unavailable_body": "Je v bezpečí na serveri v priečinku Koncepty. Skúste to znova, keď sa obnoví pripojenie.", + "try_again": "Skúsiť znova", + "close": "Zavrieť" } }, "desktop_sign_in": { diff --git a/locales/th/common.json b/locales/th/common.json index 0c34470ff..128d8564b 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6566,6 +6566,85 @@ "save_as": "บันทึกเป็น…", "copy": "คัดลอก", "copy_failed": "คัดลอกไฟล์ไม่ได้" + }, + "menu": { + "file": "ไฟล์", + "edit": "แก้ไข", + "view": "มุมมอง", + "message": "ข้อความ", + "go": "ไป", + "window": "หน้าต่าง", + "help": "ช่วยเหลือ", + "new_window": "หน้าต่างใหม่", + "print": "พิมพ์…", + "save_pdf": "บันทึกเป็น PDF…", + "switch_connection": "เปลี่ยนการเชื่อมต่อ…", + "close": "ปิด", + "quit": "ออก", + "undo": "เลิกทำ", + "redo": "ทำซ้ำ", + "cut": "ตัด", + "copy": "คัดลอก", + "paste": "วาง", + "select_all": "เลือกทั้งหมด", + "search": "ค้นหาอีเมล", + "refresh": "ตรวจสอบอีเมลใหม่", + "reading_pane": "บานหน้าต่างการอ่าน", + "pane_right": "ด้านขวา", + "pane_below": "ใต้รายการ", + "pane_off": "ปิด", + "zoom_in": "ขยาย", + "zoom_out": "ย่อ", + "zoom_reset": "ขนาดจริง", + "full_screen": "เต็มหน้าจอ", + "open_in_window": "เปิดในหน้าต่างใหม่", + "next_message": "ข้อความถัดไป", + "previous_message": "ข้อความก่อนหน้า", + "next_unread": "ข้อความที่ยังไม่ได้อ่านถัดไป", + "mail": "อีเมล", + "files": "ไฟล์", + "back": "ย้อนกลับ", + "forward": "ไปข้างหน้า", + "minimize": "ย่อหน้าต่าง", + "next_window": "หน้าต่างถัดไป", + "shortcuts": "แป้นพิมพ์ลัด", + "docs": "เอกสารประกอบ", + "settings": "การตั้งค่า…", + "about": "เกี่ยวกับ Bulwark", + "from_plugins": "เพิ่มเติมจากปลั๊กอิน…", + "pick_gone": "เมนูนี้เก่าแล้ว โปรดเปิดใหม่แล้วลองอีกครั้ง" + }, + "context": { + "add_to_dictionary": "เพิ่ม “{word}” ในพจนานุกรม", + "no_suggestions": "ไม่มีคำแนะนำ", + "open_link": "เปิดลิงก์", + "copy_link": "คัดลอกลิงก์", + "copy_address": "คัดลอกที่อยู่", + "save_image": "บันทึกรูปภาพ…", + "copy_image": "คัดลอกรูปภาพ" + }, + "closing": { + "unsaved_title": "บันทึกฉบับร่างไม่ได้", + "unsaved_detail": "หากปิดตอนนี้ สิ่งที่คุณเขียนหลังจากบันทึกครั้งล่าสุดจะหายไป", + "close_anyway": "ปิดต่อไป", + "unsent_title": "มีอีเมลที่ยังไม่ได้ส่ง", + "unsent_one": "มี 1 ข้อความรอส่ง จะส่งออกเมื่อคุณเปิด Bulwark ครั้งถัดไป", + "unsent_many": "มี {count} ข้อความรอส่ง จะส่งออกเมื่อคุณเปิด Bulwark ครั้งถัดไป", + "quit": "ออก", + "leave_title": "ออกโดยไม่บันทึกการเปลี่ยนแปลงหรือไม่", + "leave_detail": "การเปลี่ยนแปลงของคุณในหน้านี้ยังไม่ได้บันทึก", + "leave": "ออก", + "stay": "อยู่ต่อ" + }, + "spellcheck": { + "offer": "การตรวจตัวสะกดสำหรับ{language}ต้องมีพจนานุกรม Bulwark ดาวน์โหลดจาก Google ได้ ข้อความของคุณจะไม่ถูกส่งไป", + "download": "ดาวน์โหลด" + }, + "restore": { + "unavailable_title": "ตอนนี้ยังเปิดฉบับร่างนี้ไม่ได้", + "unavailable_body": "ฉบับร่างปลอดภัยอยู่บนเซิร์ฟเวอร์ ในโฟลเดอร์ฉบับร่าง ลองอีกครั้งเมื่อเชื่อมต่อได้", + "try_again": "ลองอีกครั้ง", + "close": "ปิด" } }, "desktop_sign_in": { diff --git a/locales/tr/common.json b/locales/tr/common.json index 5e5365633..7e08bdca3 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6558,6 +6558,85 @@ "save_as": "Farklı kaydet…", "copy": "Kopyala", "copy_failed": "Dosya kopyalanamadı." + }, + "menu": { + "file": "Dosya", + "edit": "Düzen", + "view": "Görünüm", + "message": "İleti", + "go": "Git", + "window": "Pencere", + "help": "Yardım", + "new_window": "Yeni Pencere", + "print": "Yazdır…", + "save_pdf": "PDF Olarak Kaydet…", + "switch_connection": "Bağlantıyı Değiştir…", + "close": "Kapat", + "quit": "Çık", + "undo": "Geri Al", + "redo": "Yinele", + "cut": "Kes", + "copy": "Kopyala", + "paste": "Yapıştır", + "select_all": "Tümünü Seç", + "search": "E-postalarda Ara", + "refresh": "Yeni E-postaları Denetle", + "reading_pane": "Okuma Bölmesi", + "pane_right": "Sağda", + "pane_below": "Listenin Altında", + "pane_off": "Kapalı", + "zoom_in": "Yakınlaştır", + "zoom_out": "Uzaklaştır", + "zoom_reset": "Gerçek Boyut", + "full_screen": "Tam Ekran", + "open_in_window": "Yeni Pencerede Aç", + "next_message": "Sonraki İleti", + "previous_message": "Önceki İleti", + "next_unread": "Sonraki Okunmamış İleti", + "mail": "E-posta", + "files": "Dosyalar", + "back": "Geri", + "forward": "İleri", + "minimize": "Küçült", + "next_window": "Sonraki Pencere", + "shortcuts": "Klavye Kısayolları", + "docs": "Belgeler", + "settings": "Ayarlar…", + "about": "Bulwark Hakkında", + "from_plugins": "Eklentilerden Daha Fazlası…", + "pick_gone": "Bu menü eskimişti. Yeniden açın ve bir kez daha deneyin." + }, + "context": { + "add_to_dictionary": "“{word}” sözcüğünü sözlüğe ekle", + "no_suggestions": "Öneri yok", + "open_link": "Bağlantıyı Aç", + "copy_link": "Bağlantıyı Kopyala", + "copy_address": "Adresi Kopyala", + "save_image": "Resmi Kaydet…", + "copy_image": "Resmi Kopyala" + }, + "closing": { + "unsaved_title": "Bir taslak kaydedilemedi", + "unsaved_detail": "Şimdi kapatırsanız, son kayıttan beri yazdıklarınız kaybolur.", + "close_anyway": "Yine de kapat", + "unsent_title": "Bazı e-postalar henüz gönderilmedi", + "unsent_one": "Bir ileti gönderilmeyi bekliyor. Bulwark’ı bir sonraki açışınızda gönderilir.", + "unsent_many": "{count} ileti gönderilmeyi bekliyor. Bulwark’ı bir sonraki açışınızda gönderilir.", + "quit": "Çık", + "leave_title": "Kaydedilmemiş değişikliklerle ayrılınsın mı?", + "leave_detail": "Bu sayfada yaptığınız değişiklikler henüz kaydedilmedi.", + "leave": "Ayrıl", + "stay": "Kal" + }, + "spellcheck": { + "offer": "{language} yazım denetimi için bir sözlük gerekir. Bulwark bunu Google’dan indirebilir. Metniniz gönderilmez.", + "download": "İndir" + }, + "restore": { + "unavailable_title": "Bu taslak şu anda açılamıyor", + "unavailable_body": "Sunucuda, Taslaklar klasöründe güvende. Bağlantı geri geldiğinde yeniden deneyin.", + "try_again": "Yeniden dene", + "close": "Kapat" } }, "desktop_sign_in": { diff --git a/locales/uk/common.json b/locales/uk/common.json index 9d6aaed35..e86001031 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6558,6 +6558,85 @@ "save_as": "Зберегти як…", "copy": "Копіювати", "copy_failed": "Не вдалося скопіювати файл." + }, + "menu": { + "file": "Файл", + "edit": "Редагування", + "view": "Вигляд", + "message": "Повідомлення", + "go": "Перехід", + "window": "Вікно", + "help": "Довідка", + "new_window": "Нове вікно", + "print": "Друк…", + "save_pdf": "Зберегти як PDF…", + "switch_connection": "Змінити підключення…", + "close": "Закрити", + "quit": "Вийти", + "undo": "Скасувати", + "redo": "Повторити", + "cut": "Вирізати", + "copy": "Копіювати", + "paste": "Вставити", + "select_all": "Вибрати все", + "search": "Пошук у пошті", + "refresh": "Перевірити нову пошту", + "reading_pane": "Область читання", + "pane_right": "Праворуч", + "pane_below": "Під списком", + "pane_off": "Вимкнено", + "zoom_in": "Збільшити", + "zoom_out": "Зменшити", + "zoom_reset": "Реальний розмір", + "full_screen": "На весь екран", + "open_in_window": "Відкрити в новому вікні", + "next_message": "Наступне повідомлення", + "previous_message": "Попереднє повідомлення", + "next_unread": "Наступне непрочитане повідомлення", + "mail": "Пошта", + "files": "Файли", + "back": "Назад", + "forward": "Вперед", + "minimize": "Згорнути", + "next_window": "Наступне вікно", + "shortcuts": "Комбінації клавіш", + "docs": "Документація", + "settings": "Налаштування…", + "about": "Про Bulwark", + "from_plugins": "Більше від плагінів…", + "pick_gone": "Це меню застаріло. Відкрийте його знову й спробуйте ще раз." + }, + "context": { + "add_to_dictionary": "Додати «{word}» до словника", + "no_suggestions": "Немає пропозицій", + "open_link": "Відкрити посилання", + "copy_link": "Копіювати посилання", + "copy_address": "Копіювати адресу", + "save_image": "Зберегти зображення…", + "copy_image": "Копіювати зображення" + }, + "closing": { + "unsaved_title": "Не вдалося зберегти чернетку", + "unsaved_detail": "Якщо закрити зараз, усе написане після останнього збереження буде втрачено.", + "close_anyway": "Закрити все одно", + "unsent_title": "Частину листів ще не надіслано", + "unsent_one": "Одне повідомлення чекає на надсилання. Воно піде під час наступного запуску Bulwark.", + "unsent_many": "Повідомлень, що чекають на надсилання: {count}. Вони підуть під час наступного запуску Bulwark.", + "quit": "Вийти", + "leave_title": "Піти з незбереженими змінами?", + "leave_detail": "Зміни на цій сторінці ще не збережено.", + "leave": "Піти", + "stay": "Залишитися" + }, + "spellcheck": { + "offer": "Для перевірки правопису мовою «{language}» потрібен словник. Bulwark може завантажити його від Google. Ваш текст не надсилається.", + "download": "Завантажити" + }, + "restore": { + "unavailable_title": "Цю чернетку зараз не можна відкрити", + "unavailable_body": "Вона в безпеці на сервері, у теці «Чернетки». Спробуйте ще раз, коли з’явиться з’єднання.", + "try_again": "Спробувати ще раз", + "close": "Закрити" } }, "desktop_sign_in": { diff --git a/locales/vi/common.json b/locales/vi/common.json index 2076cdb65..a53cd69bb 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6570,6 +6570,85 @@ "save_as": "Lưu thành…", "copy": "Sao chép", "copy_failed": "Không thể sao chép tệp." + }, + "menu": { + "file": "Tệp", + "edit": "Chỉnh sửa", + "view": "Xem", + "message": "Thư", + "go": "Đi tới", + "window": "Cửa sổ", + "help": "Trợ giúp", + "new_window": "Cửa sổ mới", + "print": "In…", + "save_pdf": "Lưu dưới dạng PDF…", + "switch_connection": "Đổi kết nối…", + "close": "Đóng", + "quit": "Thoát", + "undo": "Hoàn tác", + "redo": "Làm lại", + "cut": "Cắt", + "copy": "Sao chép", + "paste": "Dán", + "select_all": "Chọn tất cả", + "search": "Tìm trong thư", + "refresh": "Kiểm tra thư mới", + "reading_pane": "Ô đọc thư", + "pane_right": "Bên phải", + "pane_below": "Bên dưới danh sách", + "pane_off": "Tắt", + "zoom_in": "Phóng to", + "zoom_out": "Thu nhỏ", + "zoom_reset": "Kích thước thực", + "full_screen": "Toàn màn hình", + "open_in_window": "Mở trong cửa sổ mới", + "next_message": "Thư tiếp theo", + "previous_message": "Thư trước", + "next_unread": "Thư chưa đọc tiếp theo", + "mail": "Thư", + "files": "Tệp", + "back": "Quay lại", + "forward": "Tiến tới", + "minimize": "Thu nhỏ cửa sổ", + "next_window": "Cửa sổ tiếp theo", + "shortcuts": "Phím tắt", + "docs": "Tài liệu", + "settings": "Cài đặt…", + "about": "Giới thiệu về Bulwark", + "from_plugins": "Thêm từ các plugin…", + "pick_gone": "Menu này đã cũ. Hãy mở lại và thử một lần nữa." + }, + "context": { + "add_to_dictionary": "Thêm “{word}” vào từ điển", + "no_suggestions": "Không có gợi ý", + "open_link": "Mở liên kết", + "copy_link": "Sao chép liên kết", + "copy_address": "Sao chép địa chỉ", + "save_image": "Lưu hình ảnh…", + "copy_image": "Sao chép hình ảnh" + }, + "closing": { + "unsaved_title": "Không thể lưu một bản nháp", + "unsaved_detail": "Nếu bạn đóng bây giờ, nội dung bạn đã viết kể từ lần lưu gần nhất sẽ bị mất.", + "close_anyway": "Vẫn đóng", + "unsent_title": "Một số thư chưa được gửi", + "unsent_one": "Có 1 thư đang chờ gửi. Thư sẽ được gửi vào lần tới bạn mở Bulwark.", + "unsent_many": "Có {count} thư đang chờ gửi. Các thư sẽ được gửi vào lần tới bạn mở Bulwark.", + "quit": "Thoát", + "leave_title": "Rời đi khi còn thay đổi chưa lưu?", + "leave_detail": "Các thay đổi của bạn trên trang này chưa được lưu.", + "leave": "Rời đi", + "stay": "Ở lại" + }, + "spellcheck": { + "offer": "Kiểm tra chính tả cho {language} cần một từ điển. Bulwark có thể tải từ Google. Văn bản của bạn không bị gửi đi.", + "download": "Tải xuống" + }, + "restore": { + "unavailable_title": "Hiện không thể mở bản nháp này", + "unavailable_body": "Bản nháp vẫn an toàn trên máy chủ, trong mục Thư nháp. Hãy thử lại khi có kết nối.", + "try_again": "Thử lại", + "close": "Đóng" } }, "desktop_sign_in": { diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 53eefab4f..019716c37 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6558,6 +6558,85 @@ "save_as": "另存新檔…", "copy": "複製", "copy_failed": "無法複製檔案。" + }, + "menu": { + "file": "檔案", + "edit": "編輯", + "view": "檢視", + "message": "郵件", + "go": "前往", + "window": "視窗", + "help": "說明", + "new_window": "新增視窗", + "print": "列印…", + "save_pdf": "儲存為 PDF…", + "switch_connection": "變更連線…", + "close": "關閉", + "quit": "結束", + "undo": "還原", + "redo": "重做", + "cut": "剪下", + "copy": "拷貝", + "paste": "貼上", + "select_all": "全選", + "search": "搜尋郵件", + "refresh": "檢查新郵件", + "reading_pane": "閱讀窗格", + "pane_right": "在右側", + "pane_below": "在清單下方", + "pane_off": "關閉", + "zoom_in": "放大", + "zoom_out": "縮小", + "zoom_reset": "實際大小", + "full_screen": "全螢幕", + "open_in_window": "在新視窗中開啟", + "next_message": "下一封郵件", + "previous_message": "上一封郵件", + "next_unread": "下一封未讀郵件", + "mail": "郵件", + "files": "檔案", + "back": "返回", + "forward": "下一頁", + "minimize": "最小化", + "next_window": "下一個視窗", + "shortcuts": "鍵盤快速鍵", + "docs": "說明文件", + "settings": "設定…", + "about": "關於 Bulwark", + "from_plugins": "更多外掛程式選項…", + "pick_gone": "此選單已過期。請重新開啟後再試一次。" + }, + "context": { + "add_to_dictionary": "將「{word}」加入字典", + "no_suggestions": "沒有建議", + "open_link": "開啟連結", + "copy_link": "拷貝連結", + "copy_address": "拷貝位址", + "save_image": "儲存圖片…", + "copy_image": "拷貝圖片" + }, + "closing": { + "unsaved_title": "草稿無法儲存", + "unsaved_detail": "如果現在關閉,自上次儲存以來所寫的內容將會遺失。", + "close_anyway": "仍要關閉", + "unsent_title": "有郵件尚未寄出", + "unsent_one": "有 1 封郵件正在等待寄出,將在您下次開啟 Bulwark 時寄出。", + "unsent_many": "有 {count} 封郵件正在等待寄出,將在您下次開啟 Bulwark 時寄出。", + "quit": "結束", + "leave_title": "有未儲存的變更,確定要離開嗎?", + "leave_detail": "您在此頁面所做的變更尚未儲存。", + "leave": "離開", + "stay": "留下" + }, + "spellcheck": { + "offer": "{language}拼字檢查需要字典。Bulwark 可以從 Google 下載。您的文字不會被傳送。", + "download": "下載" + }, + "restore": { + "unavailable_title": "目前無法開啟此草稿", + "unavailable_body": "草稿安全地保存在伺服器的「草稿」中。連線恢復後請再試一次。", + "try_again": "重試", + "close": "關閉" } }, "desktop_sign_in": { diff --git a/locales/zh/common.json b/locales/zh/common.json index 770f679eb..9ad6edd12 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6558,6 +6558,85 @@ "save_as": "另存为…", "copy": "复制", "copy_failed": "无法复制文件。" + }, + "menu": { + "file": "文件", + "edit": "编辑", + "view": "视图", + "message": "邮件", + "go": "前往", + "window": "窗口", + "help": "帮助", + "new_window": "新建窗口", + "print": "打印…", + "save_pdf": "另存为 PDF…", + "switch_connection": "更改连接…", + "close": "关闭", + "quit": "退出", + "undo": "撤销", + "redo": "重做", + "cut": "剪切", + "copy": "复制", + "paste": "粘贴", + "select_all": "全选", + "search": "搜索邮件", + "refresh": "检查新邮件", + "reading_pane": "阅读窗格", + "pane_right": "在右侧", + "pane_below": "在列表下方", + "pane_off": "关闭", + "zoom_in": "放大", + "zoom_out": "缩小", + "zoom_reset": "实际大小", + "full_screen": "全屏", + "open_in_window": "在新窗口中打开", + "next_message": "下一封邮件", + "previous_message": "上一封邮件", + "next_unread": "下一封未读邮件", + "mail": "邮件", + "files": "文件", + "back": "后退", + "forward": "前进", + "minimize": "最小化", + "next_window": "下一个窗口", + "shortcuts": "键盘快捷键", + "docs": "文档", + "settings": "设置…", + "about": "关于 Bulwark", + "from_plugins": "更多插件选项…", + "pick_gone": "该菜单已过期。请重新打开后再试一次。" + }, + "context": { + "add_to_dictionary": "将“{word}”添加到词典", + "no_suggestions": "无建议", + "open_link": "打开链接", + "copy_link": "复制链接", + "copy_address": "复制地址", + "save_image": "保存图片…", + "copy_image": "复制图片" + }, + "closing": { + "unsaved_title": "草稿无法保存", + "unsaved_detail": "如果现在关闭,自上次保存以来所写的内容将会丢失。", + "close_anyway": "仍然关闭", + "unsent_title": "有邮件尚未发送", + "unsent_one": "有 1 封邮件正在等待发送,将在您下次打开 Bulwark 时发出。", + "unsent_many": "有 {count} 封邮件正在等待发送,将在您下次打开 Bulwark 时发出。", + "quit": "退出", + "leave_title": "有未保存的更改,确定离开吗?", + "leave_detail": "您在此页面所做的更改尚未保存。", + "leave": "离开", + "stay": "留下" + }, + "spellcheck": { + "offer": "{language}拼写检查需要词典。Bulwark 可以从 Google 下载。您的文字不会被发送。", + "download": "下载" + }, + "restore": { + "unavailable_title": "目前无法打开此草稿", + "unavailable_body": "草稿安全地保存在服务器的“草稿”中。连接恢复后请重试。", + "try_again": "重试", + "close": "关闭" } }, "desktop_sign_in": {